OWNER-OPS — escalation ledger for the autonomous 1:1 workstream run
Append-only. Created by the Fable coordinator session executing
archive/2026-08-22-doc-reset/plans/WORKSTREAM-EXECUTION-BRIEF.md(run started 2026-07-27). Each entry: date · blocker · what the run did instead · exact owner action required.Closed entries live in
archive/2026-09-02-owner-ops-split/(split 2026-09-02). Append-only still holds for what remains: entries below are never rewritten or deleted — when one closes, it moves to that set and its row leaves the index.
The owner’s to-do list is clockwork, not this file (owner decision 2026-10-17, card
decisao:registo-unico-2026-10-17). An open item that has a clockwork card names its key in the
index below. Ask cw list or admin-shell’s Cards tab what is open, and don’t copy a card’s steps
here. Credentials are named, scoped and dated only in ecossistema credenciais.md. The owner steps
that create or place them are cards secrets:S1…S36 (the secrets hub’s six questions:
secrets:S25; its own tokens: secrets:S26). This file keeps the dated entries that the index
points into.
Open owner actions (index — 2026-09-02)
Section titled “Open owner actions (index — 2026-09-02)”Everything below this table is still open. The index exists because 39 entries accumulated over five weeks with resolved items reading exactly like live ones; the resolved seven are now archived.
Read this first: most of the list was ONE un-run action, now half-resolved. IMPORT_RUNTIME
was "render" (workers/wrangler.jsonc) and the Render image of apps/bondlayer-worker had never
been rebuilt, so ~16 entries were facets of that single rebuild + the re-import behind it — not
sixteen separate jobs. Update 2026-09-14: row 2 (the container cutover) is DONE — see
§ 2026-09-02 “Importer container: Pass E executed, cutover flipped” and § 2026-09-03 below;
skyfall-api has run IMPORT_RUNTIME="container" since 2026-09-02 and Render no longer serves
importer traffic. That retires row 1’s “rebuild the Render image” half permanently (every merge to
apps/bondlayer-worker/ now auto-deploys the container) — what’s left of row 1 is just “re-import”,
tracked at § 2026-09-13 item 3. Row 2’s remaining half (Pass F: decommission the actual Render
service + retire its vars) is still open — see the new entry at the end of this file.
| # | Open owner action | Entry (§ date) | Note |
|---|---|---|---|
| 1 | § 2026-08-14 (the re-import runbook — start here) | Carries D137, D142, D158, D159, D160, D162, D163, D166, D167, D174, D177, D:filter-context (S15a), D:filter-context-member (S15b), D:import-color-role-repoint, the fidelity batch, and the #386 security-hotfix ordering. Entries §07-27 (Phase 0.3), §07-27 (Phase 4), §07-28 (#382/#383), §07-28 (#386), §08-01 (D119 order), §08-03, §08-04, §08-12, §08-13 ×4, §08-21 ×3, §08-23 are all this one action. ↳ 2026-09-14: the “rebuild the Render image” half is moot — row 2 shipped, and every merge to apps/bondlayer-worker/ now auto-deploys matriz-import-runtime. What’s left is the re-import, tracked at § 2026-09-13 item 3. |
|
| 2 | § 2026-09-02 | Retired row 1’s Render rebuild permanently once flipped. Rollback is one var. ↳ 2026-09-14: Pass E (cutover) is DONE — IMPORT_RUNTIME="container" live on skyfall-api since 2026-09-02, see §§ 2026-09-02/2026-09-03 below. Pass F (decommission — suspend/delete the Render service, retire the three onrender.com vars, rotate IMPORT_WORKER_TOKEN) is still open; tracked in the new entry at the end of this file. |
|
| 3 | Merge or drop origin/claude/phase-3c-link-config |
§ 2026-07-28 (crash-recovery handoff) | Verified 2026-09-02: branch still exists on the remote, still unmerged into master. |
| 4 | deploy-api.yml/deploy-editor.yml vs the Cloudflare Git integrations |
§ 2026-08-02 | ↳ 2026-09-23: resolved in favor of the Cloudflare Git integrations — both workflows are now workflow_dispatch-only (break-glass). To reverse it, restore their push: blocks. See § 2026-09-23. |
| 5 | Set BONDLAYER_TRIMSCAN_URL on prod skyfall-api (+ confirm IMPORT_WORKER_TOKEN); two style-fidelity calls unmade |
§ 2026-08-02 | Without it the end-of-import trimscan silently no-ops. |
| 6 | Apply db/migrations/20260824_drop_legacy_target_project_fk.sql |
§ 2026-08-24 | Still recorded NOT APPLIED in db/MIGRATIONS.md. Idempotent, non-destructive, defence-in-depth. |
| 7 | Wipe the imported sites, then sweep the residue | § 2026-08-24 | POST /api/ops/projects/:id/imports/purge then …/orphans/sweep. Needs the OPS_ADMIN_TOKEN secret; dry-run first. |
| 8 | Repair 5 corrupted site_pages.seo rows |
§ 2026-08-10 | Proposal written, NOT executed. All 5 are on unpublished throwaway targets. |
| 9 | Decide on assets.metadata.public never being cleared |
§ 2026-08-15 | PROPOSAL, nothing executed. An asset that stops being referenced stays world-readable. |
| 10 | Provision the archive-index compile container, or accept the in-Worker ceiling | § 2026-08-14 | A seam exists; nothing is deployed. Publish degrades safely either way. |
| 11 | Attach the Cloudflare Pages custom domains for the two docs sites | § 2026-08-22 | docs.matriz.design / dev.matriz.design. |
| 12 | 0039_canvas_comments.sql + its RLS sidecar to prod Neon0038_site_auth_providers (+ its sidecar), 0040_tired_darkhawk and the never-applied 20260824_collection_access_rls.sql; ledger rows 44–46; GET /api/comments → 200 on prod. |
§ 2026-09-13 | Still open from the same audit: db/migrations/20260824_drop_legacy_target_project_fk.sql (drops the legacy sites_organization_id_organizations_id_fk on targets — still present on prod; destructive-shaped, owner-apply). |
| 13 | § 2026-09-13 | Custom-CSS migration (D261), screen-root floor (D262), text-field types (D263), entity single-escape (D264), filter defaults (D265), carousel current/rewind (D266) are all invisible until this runs. ↳ 2026-09-14: this row’s premise was already stale when written — the cutover (row 2) ran 2026-09-02, before this 2026-09-13 wave landed, so there was never a Render image to rebuild for these fixes. Same action as row 14: re-import. | |
| 14 | Re-import RPM, Simão and both 12x12 targets | § 2026-09-13 | Every importer-side fix in D261–D266 rides the re-import; it also stamps data-matriz-lang for the new language switcher (D260). |
| 15 | Republish RPM, Simão and both 12x12 targets | § 2026-09-13 | All four are multi-locale (supported_languages: ["pt","en"], confirmed via API 2026-09-13) — republish is what emits the /en/* documents + hreflang (D260). Re-import (row 14) first, or the PT/EN toggles stay the old <p link_locale> leak. |
| 16 | Figma-sync the new preview chrome — PreviewTopBar, AppSidebar “To-do” mode icon, comment pin states, CommentThreadPopover, To-do panel row |
§ 2026-09-13 | figma-editor-sync skill against file bZ17CtfEJ11rcV8UHNGKvh (D258/D259). |
| 17 | Cloudflare “Workers Builds: skyfall-api” check fails on PR branches (#669/#670/#671) — check the dashboard build log | § 2026-09-13 | Passed on #668; local wrangler deploy --dry-run bundles clean on the integration branch, so this looks like the CF preview-build environment, not the code. |
| 18 | Invite the 416 seeded 12x12 members to sign in | § 2026-09-13 (invite send) | Needs the member area enabled + the sign-in page published first; then CMS → Members → Invite all (D:member-invite-send). |
| 19 | Apply the recurring-structures shape to 12x12 — editions schedule field, symbol, works form + gate, collapse pass, members area, admin table |
§ 2026-09-14 | D:recurring-structures. Depends on rows 1/14 (re-import) already having landed editions/users_folders; independent of row 18 (invites can run before or after). |
| 20 | Restore the 3 CV “Tab Link” nodes’ current style layer (target 05f6e9ef-369a-4797-a0c1-0c6ebe88ef86) |
§ 2026-09-14 | D:computed-style-layer-loss. The CODE defect is fixed (PR #694 + its follow-up); these three rows were already flattened to {} on disk and no code change un-flattens them. Subsumed by row 14 if the re-import lands first. |
| 21 | 0041_worthless_agent_brand.sql + db/migrations/20260915_target_deletions_rls.sql to prod Neon1789420253954; RLS on with 0 policies / 0 tenant grants. |
§ db/MIGRATIONS.md (both entries carry the exact one-transaction recipe + ledger row) |
D:delete-end-to-end. Must land WITH OR BEFORE the deploy carrying workers/src/lib/target-deletion-job.ts — DELETE /api/targets/:id inserts the debt row in the same request that deletes the target, so code-ahead-of-migration means every site delete 500s on a missing relation (the 0010 failure shape). Purely additive, starts empty; sidecar is service-only deny-closed. db:migrate can’t run from the agent sandbox (TCP :5432 blocked) — MCP over HTTPS, dry-run on an ephemeral branch first. |
| 22 | § 2026-09-20 | Spec: plans/COLLECTION-CONTROLS-WORKSTREAM.md. All 19 implementation items (S18–S32, S34–S37) merged into claude/confident-cray-5evewb, PR #720, ready for review — see the plan’s §13. RPM/Simão: owner said ignore. 12x12 was deleted by the owner ahead of re-import; run S33 (the § 2026-08-14 re-import runbook on all four targets + republish) once PR #720 merges to master. |
|
| 23 | Move CI off GitHub-hosted minutes — ↳ 2026-09-27: jobs now pick a runner by tier: CI_CHECKS_RUNS_ON (small VPS), CI_HEAVY_RUNS_ON (big box), CI_DOCKER_RUNS_ON, and CI_RUNS_ON (the fallback, and the only setting for secret-bearing jobs). Do NOT set CI_RUNS_ON to a self-hosted label. What to set: § 2026-09-27. |
§ 2026-09-23, § 2026-09-27 | Every Actions job has failed without starting since 2026-09-23 (spending limit). ↳ Cards: issue1063:G31 (done 2026-10-16: light CI routed to Oracle until issue1063:G28, the Hetzner disk decision, still open); plano:jobs-github-hosted (open: what to do with the secret-bearing jobs still on GitHub-hosted runners). |
| 24 | Marketing test queue — run before/around the first campaign launch (2026-10-04) | § 2026-10-03 | Real-inbox checks the code can’t do: dark mode per app, Outlook, mobile, unsubscribe, the new send gates. Migration 0052_email_library is already applied (MCP), nothing to deploy-order. |
| 25 | Motion Studio: deploy the render host, supply library tracks, set the cap, apply the migrations, choose roles | § 2026-10-18 | Code merged; video OFF (MOTION_RENDER_RUNTIME="off"). Nothing deployed, nothing applied to prod. |
Reference entries below carry no owner action and are kept because they are still true: § 2026-08-10 (a PR with merge conflicts silently gets NO checks), § 2026-07-27/07-28 expectation notes on what re-publish and slider routing will visibly change after the re-import.
2026-07-27 — Phase 0.3: re-import against the deployed stack
Section titled “2026-07-27 — Phase 0.3: re-import against the deployed stack”- Blocker: verifying the Phase 0 baseline with raw (post-#367) CMS keys requires (1) the
deployed Worker to carry
D:import-verbatim-payload— but GitHub Actions deploys have been dead since 2026-07-24 and the CF Workers Builds/Pages Git integrations deploy on merge tomaster, which this run cannot force-verify against prod — and (2) an authenticated import session (Clerk) plus, for importer-side fixes, a Render Docker image rebuild ofapps/bondlayer-worker. None of these credentials/operations are available to the run. - Done instead: importer changes are verified against the raw captures in
imports/(simaomartinez, achronima_art, 12x12) via the Python test suites and fixture-driven assertions. Phase 0.3 stays ⏳ in the workstream doc. - Owner action: after the Wave-1 importer phases merge — rebuild the Render image for
apps/bondlayer-worker, re-run the Bondlayer import of “Simao Martinez” (target111659a5-fe01-4ebc-81a4-45abd8d8f700), then re-verify the Phase 0 baseline table inplans/MATRIZ-1TO1-IMPORT-AND-UI-WORKSTREAM.md(raw field keys,text_technique_dimensions.ptpresent, locale bags on one convention).
2026-07-27 — Phase 4 merged: apps/bondlayer-worker/ changed ⇒ Render rebuild required
Section titled “2026-07-27 — Phase 4 merged: apps/bondlayer-worker/ changed ⇒ Render rebuild required”- Blocker: the Python importer deploys as a Render Docker service; merging does NOT redeploy it.
- Done instead: importer changes (run_live default for imported custom_code, print-CSS+JS warning) are merged and test-verified against the raw captures.
- Owner action: rebuild the Render image for
apps/bondlayer-worker(skyfall-keru.onrender.com) after the Wave-1 importer phases (1, 3, 4) merge — one rebuild at the end covers all — then re-import per the Phase 0.3 entry above. ↳ 2026-09-02 (D:importer-containers): the Render rebuild this asks for is superseded once the container cutover below runs — after Pass E,apps/bondlayer-worker/merges deploy thematriz-import-runtimecontainer automatically and the Render service is decommissioned (Pass F). Until the flip, this action stands. See § 2026-09-02 at the end of this file.
2026-07-27 — Phase 1 merged: re-publish flips imported master values (expected)
Section titled “2026-07-27 — Phase 1 merged: re-publish flips imported master values (expected)”- Note, not a blocker: after the Phase 1 locale collapse (D97), the next publish of the
imported simaomartinez target (
111659a5) changes the 34 xlsx-imported artwork titles from theirptstrings to their master (default) values — expected and correct under the neutral master slot. The stored items are still pre-#367 camelised until the re-import (Phase 0.3 entry). - Owner action: none beyond the Phase 0.3 re-import + Render rebuild already queued above.
2026-07-28 — Phase 3 merged: slider routing is visibly different on re-import (expected)
Section titled “2026-07-28 — Phase 3 merged: slider routing is visibly different on re-import (expected)”- Note, not a blocker: per owner decision D-1, sliders route by settings, not chrome: on the
three captures 5 route to the Embla
carousel(working arrows/dots/autoplay) and 10 to the zero-JS scroll-snapslider— those 10 lose their (JS-dependent, otherwise-dead) arrows/dots, each recorded as a lossy-import notice, and gain a stamped swipe/scroll affordance instead. - Owner action: none beyond the already-queued Render rebuild + re-import. If working
arrows on plain sliders matter more than staying script-free, say so and the routing gains a
chrome trigger — one-line change, noted in
_slider_target’s docstring.
2026-07-28 — Importer merges since the last Render rebuild note (#382, #383)
Section titled “2026-07-28 — Importer merges since the last Render rebuild note (#382, #383)”- What changed in
apps/bondlayer-worker/: 3b-C (#382 — group-by translation_extract_group_by,_repeaterstate-rule pinning with honest 0/39 reporting) and Phase 6 (#383 —_ref:multiRef_*→ nativearray_field/parent_contextconversion under the 6-clause rule, scope-depth fix,nested_ref_group_by_dropped,inert_nested_controlsreporting). - Owner action: the Phase 0.3 entry above already covers it — rebuild the Render image for
apps/bondlayer-workerand re-run the Bondlayer import; these two merges must be in that build. The re-imported site is also the “Done when” evidence for Phases 3/6 (nested repeaters render rows; grouped repeaters keep their headers).
2026-07-28 — Session-crash recovery note (coordinator handoff)
Section titled “2026-07-28 — Session-crash recovery note (coordinator handoff)”- The 2026-07-27 coordinator session crashed after pushing three finished-but-unmerged phase
branches. A recovery session landed them: 3b-C → #382 (D109/D110), Phase 6 → #383 (D111,
after an independent adversarial review found 2 preview↔publish parity breaks + a D110
interaction, all fixed pre-merge). Still unmerged and untouched:
origin/claude/phase-3c-link-config(3c.4 — complete per its commits, needs master merge + gates + the required opus review before merging). Not started: 3c.3 (legacy element family), Phase 7 (print).origin/claude/phase-3-carouselandorigin/claude/phase-4-print-warningare STALE leftovers — their content merged via #369/#370; safe to delete. - Known follow-up from the Phase 6 review (F7, pre-existing): “Don’t repeat — bind fields only”
is a no-op on a persisted nested repeater source (
DataBindingProperties.tsx~:830) — only Clear removes it.
2026-07-28 — Import-worker security hotfix (#386): Render redeploy + deploy ORDER + residual
Section titled “2026-07-28 — Import-worker security hotfix (#386): Render redeploy + deploy ORDER + residual”- What shipped: CORS origin exact/anchored-regex match (no more suffix reflection), a per-job
capability token on
GET /import/jobs/:id(the jobs body is the full site JSON), opaque error responses, and job-store eviction. Fixes four holes live on the deployed Render worker via #380. - Owner action — Render rebuild of
apps/bondlayer-worker(skyfall-keru.onrender.com), with deploy ORDER: the editor adapter now sendsX-Job-Token. A new worker paired with an OLD editor bundle will 401 every poll. Deploy the editor first (it tolerates a token-less worker), or ship both in the same window. This is in addition to the Render rebuild already queued for the importer merges (#382/#383/#385 etc.) — one rebuild covers all. ↳ 2026-09-02 (D:importer-containers): the Render rebuild this asks for is superseded once the container cutover below runs — after Pass E,apps/bondlayer-worker/merges deploy thematriz-import-runtimecontainer automatically and the Render service is decommissioned (Pass F). Until the flip, this action stands. See § 2026-09-02 at the end of this file. - Residual risk (needs a code follow-up, not an owner op):
POST /importstays gated on the Origin check only, because its sole caller is the browser SPA which cannot hold a shared secret (aVITE_*value is public). Origin is browser-enforced, so a non-browser attacker can still spend the worker’s CPU on an arbitrary ZIP. Closing it needs a first-party Cloudflare Worker proxy (workers/src) that holdsIMPORT_WORKER_TOKENand forwards — tracked as follow-up work, NOT shipped here (workers/src was out of scope for the hotfix).
2026-07-28 — RUN CLOSE-OUT: workstream complete, outstanding owner actions
Section titled “2026-07-28 — RUN CLOSE-OUT: workstream complete, outstanding owner actions”The 1:1 import + UI workstream is fully merged (D95–D115, PRs #369–#391). Everything below is what remains for the owner — nothing blocks further code work.
- One Render rebuild + re-import (
skyfall-keru.onrender.com,apps/bondlayer-worker) — every importer merge (Phases 1/3/4/5/6/7/8.2, 3b-C, 3c.3, the security hotfix #386) is code-complete but the Python worker only redeploys from its own pipeline. After the rebuild, re-import “Simao Martinez” (target111659a5) and ACHRONIMA, then re-verify the Phase 0 baseline anddocs/specs/REPEATER-CONSTRAINT-MODEL.md§8 against the re-imported site (that is the final acceptance the workstream’s “Definition of done” names). Expected visible changes: raw CMS field keys,text_technique_dimensions.ptpresent, locale bags on one convention, 34 artwork titles on their master values, 10/15 sliders as zero-JS scroll-snap. ↳ 2026-09-02 (D:importer-containers): the Render rebuild this asks for is superseded once the container cutover below runs — after Pass E,apps/bondlayer-worker/merges deploy thematriz-import-runtimecontainer automatically and the Render service is decommissioned (Pass F). Until the flip, this action stands. See § 2026-09-02 at the end of this file. - Editor deploy MUST precede the worker deploy for the security hotfix (#386): the adapter now
sends
X-Job-Token; a new worker with an old editor bundle 401s every poll. (Editor tolerates a token-less worker.) - Phase 8.1 — capture the Bondlayer
usersrows (authenticated API / in-editor scrape / support request); then the D101 member-mapping runs. Also needs a bulk member-invite endpoint (found missing during 8.2) before mapped members can be loaded. - Residual
POST /importexposure (#386 deviation): non-browser CPU abuse; needs a first-party CF Worker proxy holdingIMPORT_WORKER_TOKEN. Code follow-up, not an owner op. - Prod DB schema is fully current (0000–0033 + all RLS sidecars applied + recorded). Nothing to do.
Deferred capability gaps recorded during the run (future phases, not regressions)
Section titled “Deferred capability gaps recorded during the run (future phases, not regressions)”- Per-item link binding in repeaters (3c.4/D114): clones share one
link_config, so “card → this item’s detail page” isn’t expressible yet. - Native grouped-list authoring (3b-C/D109): group-by imports and renders, but the editor can only inspect an imported grouping, not author a new one (header/member split needs the native model).
- Empty/Loading: the permanently-hidden loading-skeleton bytes on filterable lists are a deliberate placeholder for a future genuinely-async runtime path.
2026-08-01 — Import-proxy enforcement (D119): deploy order
Section titled “2026-08-01 — Import-proxy enforcement (D119): deploy order”- What changed: browser imports now go through
POST /api/import-worker/importon skyfall-api (Clerk-authed, holdsIMPORT_WORKER_TOKEN); the Render worker’sPOST /importstarts REQUIRING that token the moment its env hasIMPORT_WORKER_TOKENset (fail-open until then). - Owner order for the (already pending) Render rebuild: (1) confirm
IMPORT_WORKER_TOKENis set on prodskyfall-api(it should be —/materializealready uses it) and that the Worker+editor auto-deploys for this merge landed; (2) THEN rebuild the Render image — the same rebuild that picks up every importer change from the run — withIMPORT_WORKER_TOKENset in its env, which flipsPOST /importenforcement on. Rebuilding Render FIRST would 401 imports from any not-yet-updated editor tab; the safe order is Worker/editor first, Render last (same ordering rule as #386).
2026-08-02 — Phase 8.1 UNBLOCKED for xlsx captures (D122, D:members-from-xlsx); the 2026-07-27 entry it supersedes is now archived
Section titled “2026-08-02 — Phase 8.1 UNBLOCKED for xlsx captures (D122, D:members-from-xlsx); the 2026-07-27 entry it supersedes is now archived”- The blocker recorded there (“the xlsx CMS export does not contain the virtual
userscollection”) was read off achronima/simaomartinez and is not universal:imports/12x12/CMS xlsx/export_users.xlsxholds 416 member records (no password column, consistent with 8.3(a)). The importer now maps that sheet automatically (members.json+member_items.jsonbeside the output). - The same read found a live privacy defect, now fixed: until this change, importing 12x12 wrote
all 416 members’ names, emails and phone numbers into public
content_items(445 distinct emails in the payload, vs 3 after — the 3 are an ordinary author-created “0.3 Sign-ups” collection, one address being the owner’s own; whether THAT collection should also be private is an owner call). - Owner actions: (1) the pending Render rebuild is now also privacy-relevant — an import against
the currently deployed image STILL leaks; rebuild before any further 12x12 import. (2) If 12x12
was already imported to a live target, delete the
users/users_folderscontent items from it. (3) 8.1 stays open only for captures whose export omits the sheet (capture via the authenticated API per the 2026-07-27 entry, archived). (4) Before members actually load: the bulk/invite member-creation endpoint, then a two-pass seed (members → visitorIds →member_items.jsonrows as content_items).
2026-08-02 — Bulk member seed shipped (D:member-seed); item (4) of the entry above is CLOSED
Section titled “2026-08-02 — Bulk member seed shipped (D:member-seed); item (4) of the entry above is CLOSED”- What was missing is now built:
POST /api/targets/:targetId/members/bulk(workers/src/routes/members-admin.ts+workers/src/lib/member-seed.ts) — builder-authed (Clerk +projectAccessMiddleware+members:manage), takes the importer’smembers.jsonandmember_items.jsonverbatim, and runs BOTH passes in one request: members →site_visitors/site_members, thenmember_items→content_itemswithdata.visitorIdin the exact shapeGET /members/me/itemsreads (proven by a read-back test through the real handler). Idempotent on(target_id, lower(email))— the same key the magic-link redeem upserts on — so a re-run updates and never duplicates;status/session_generationare never overwritten (a re-seed cannot un-block a member), andprofilemerges so member self-edits survive. No schema change and no migration were needed. Passwords remain excluded end to end. - Runbook + curl:
docs/architecture/BONDLAYER-IMPORT-PIPELINE.md§ “Members: seeding the audience after an import”. - Owner actions to actually load 12x12’s 416 members:
- Rebuild the Render image first (still pending from the entries above) and re-import 12x12 — the artifacts only exist post-rebuild, and the pre-rebuild image still leaks members into public CMS.
POST /api/targets/<12x12 target>/members/bulkwith the two artifacts (see the runbook). The reply’sskipped[]names bad rows by index/Bondlayer id only — no addresses in it.PATCH /api/targets/<id>/member-settings→{"enabled":true,"joinMode":"invite"}. Untilenabledis true,/members/request-code404s and no seeded member can sign in; the seed’s responsenotes[]says so explicitly.- Confirm
MEMBER_SESSION_SIGNING_KEYis set on prodskyfall-api(already recorded as set) — without it the redeem 503s and the whole cutover is inert. - Members sign in through the EXISTING D73 magic link.
email_verified_atis deliberately left NULL by the seed (seeding is not proof of address control); the first redeem stamps it.
- Deliberately NOT done: the import worker does not call the seed itself.
api_writer.pyruns with the server-to-serverIMPORT_WORKER_TOKEN, not a builder session, and giving the one route that writes hundreds of people’s personal data a second, non-Clerk auth mode is a worse trade than one explicit curl.member_mapper.py’s CLI now prints the exact command instead (apps/bondlayer-worker/changed ⇒ folded into the same pending Render rebuild).
2026-08-02 — GitHub Actions is live again; deploy-path overlap needs an owner pick
Section titled “2026-08-02 — GitHub Actions is live again; deploy-path overlap needs an owner pick”- Actions resumed working today (payment fixed). The Workers integration suite now runs in CI only
(needs the
NEON_DATABASE_URLsecret; it self-skips locally) — it immediately caught a real publish regression (D126). CLAUDE.md’s “CI is local, permanently” section is updated. - Owner pick needed:
deploy-api.yml/deploy-editor.ymlnow run AND succeed on pushes to master — in parallel with the Cloudflare Workers-Builds/Pages Git integrations that deployed everything while Actions was dead. Two deploy paths per merge is a race (last-writer-wins, usually harmless but undebuggable when they diverge). Disable ONE: either turn off the two GitHub deploy workflows, or disconnect the CF Git integrations. No action taken by the run — both were left as found. - PR #405 (
D:text-trim, another session) merged without claiming a D-number — that ledger entry is still owed by its session.
2026-08-02 — Trimscan env on prod (D131 rider) + two style-fidelity calls waiting on the owner
Section titled “2026-08-02 — Trimscan env on prod (D131 rider) + two style-fidelity calls waiting on the owner”- Set
BONDLAYER_TRIMSCAN_URL(+ confirmIMPORT_WORKER_TOKEN) on prodskyfall-api— without them the end-of-import trimscan dispatch silently no-ops, and 12x12’s fonts (Arges, Gramatika, Area) are not in the built-in 39-family metrics registry, so Safari/Firefox render untrimmed while Chrome trims natively. - Two divergence calls (D131(c), unmade on purpose):
font-weight: 700px— 222 styles render NOT-bold on the live site only because Bondlayer’s generator px-suffixed them; keep author-intent bold (current behavior, recommended) or match the live rendering? Andz-index: 10px(26 styles, stacking-relevant — matching the live/broken behavior is the safer default). Say the word and the importer gets the corresponding rule either way.
2026-08-03 — The 2026-08-03 Render rebuild is already stale (D137); publish unblocks via D:publish-promote-all
Section titled “2026-08-03 — The 2026-08-03 Render rebuild is already stale (D137); publish unblocks via D:publish-promote-all”- Rebuild Render again + re-import 12x12. The owner’s rebuild (before the 2026-08-03 import of
target
dba0a165-…) predates D137’s importer fixes: barebackground-imageurls →url("…"),$p<n>,preset stripping,assets.bondlayer.com→cdn.bndlyr.comfor ALL assets (was fonts only),background-positionshorthand composition, and slider nav chrome routing to Embla. None of these are load-time migrations — the currently imported rows keep bare urls and control-less sliders until a rebuild + fresh re-import. - Publish now works from the TopBar once
D:publish-promote-allmerges (in flight at writing): the “Publish isn’t really working” report was the 409 “Nothing to publish” — imports write every rowstatus='draft', and only per-page publish promoted. Site-level publish now takes every draft page live ({ promote: 'all' }from the editor). After it merges: press Publish on 12x12 and all 35 pages promote + build. - Run trimscan on 12x12 before republish (extends the D131 rider above): D:text-baseline’s metrics-derived leading lands 3474 of 3479 stamped nodes on the flat unmeasured constant because Gramatika/Area/Arges have no metrics rows — trimscan (or simply opening the editor, which measures on font load) moves them to the metrics path.
2026-08-04 — One Render rebuild now covers D137 + D142
Section titled “2026-08-04 — One Render rebuild now covers D137 + D142”- The pending Render redeploy of
skyfall-keru.onrender.comcarries BOTH importer decisions: D137 (background-imageurl()wrapping,$p<n>,preset stripping, private-host asset normalization,background-positionshorthand, slider chrome → Embla) and D142 (cross-screen duplicate element-id dedupe + the payload uniqueness hard-fail). - After the rebuild, re-import both captures to materialize the fixes: 12x12 (backgrounds
and slider controls) and simaomartinez (the 12 nodes that used to collapse at upsert — its
homepageandcw0ERY5GuAscreens share element ids from Bondlayer’s screen-duplicate feature). - The uniqueness guard HARD-FAILS an import whose payload still carries duplicate node ids. That is deliberate: all three reference captures build clean post-fix, so a failure means a real importer bug rather than a capture quirk — send the diagnostics rather than retrying.
2026-08-10 — A PR with merge conflicts silently gets NO checks at all
Section titled “2026-08-10 — A PR with merge conflicts silently gets NO checks at all”Worth knowing because it looks exactly like “CI is broken again”, and because a PR with no checks renders almost identically to a PR with no failures.
pull_request-triggered workflows run against the merge commit GitHub computes between the head
branch and the base. When the PR conflicts (mergeable_state: "dirty") that merge commit cannot be
computed, so no workflow runs are created at all — not queued, not failed, simply absent, while
the Cloudflare Pages/Workers Builds checks (which build the head commit directly) keep reporting
green beside them. Nothing in the PR view says why.
Seen twice today, and the correlation is exact: PRs #443 and #444 were up to date with master and
got their runs on every push; #442 and #445 had each fallen behind a freshly merged sibling and
conflicted on DECISIONS.md, and had zero runs for their branch — including across a full
open → close → reopen cycle on #445, which is what first sent me looking for an event-delivery
problem that does not exist.
The benign twin: a PR can also legitimately have no Actions runs because its diff misses the
workflows’ paths: filters — a docs-only PR touching STATUS.md matches neither frontend/** nor
packages/**, so nothing runs and nothing is wrong. The two look identical in the PR view, which is
the whole problem: check the diff before concluding either way.
Practice: merge master into the branch and resolve before expecting checks, and treat “no check
runs” as unknown, never as passing. pull_request_read with method: "get" reports
mergeable_state directly, which is the fastest way to tell the two apart.
This is ordinary GitHub behavior rather than a repo misconfiguration, so it is unrelated to the
still-unexplained CI_RUNS_ON runner-allocation question.
2026-08-10 — Five corrupted site_pages.seo rows: proposed repair (NOT executed)
Section titled “2026-08-10 — Five corrupted site_pages.seo rows: proposed repair (NOT executed)”The write-path fix (D:cms-write-keys) stops the transport rewriting JSONB keys in the database.
It does not undo what was already written. A full sweep of all 57 live JSONB columns found exactly
one damaged surface: 5 rows of site_pages.seo, all on unpublished throwaway targets (“test 2”
×2, “A test”, “simaomartinez-test” ×2). content_items.data, content_types.schema and all of
dom_nodes are clean — 12,816 of 12,836 nodes were importer-written and never went through the SPA.
The damage is inert. Publish reads only seo.title, seo.description and seo.noindex, none
of which are affected, and all three targets are is_published = false. Editing a page’s SEO also
self-heals it, because PATCH /api/site-pages/:id replaces the whole seo object. So this can wait
for, or be skipped in favour of, one manual edit per page.
If it is repaired, it must be an explicit key map, not a recursive camel→snake. A blanket
inverse would rename bindings.description.maxLength, which is camel by contract
(SeoBinding.maxLength in frontend/src/lib/seo-extraction.ts) — creating fresh corruption of
exactly the kind this work exists to stop. The correct map:
- top level:
ogType→og_type,ogImage→og_image,twitterCard→twitter_card,structuredData→structured_data - under
structuredData:typeOverride→type_override,customFields→custom_fields - under
bindingsandoverrides:ogImage→og_image - everything else, including
maxLength, left alone
ogImage and the nested keys are in this list because the first pass missed them: row
4a68d46b-9118-4e50-bee4-e4ec7cff1e23 carries neither ogType nor twitterCard and would have
been skipped entirely by the original predicate.
Reversibility checked: no affected row already carries a snake twin of any key being renamed, so the rename cannot collide or lose a value. Order matters — the transport fix must ship first, or the next save through the SPA re-corrupts whatever is repaired.
Nothing has been run against these rows; they are byte-identical to their 2026-07 state.
2026-08-12 — The pending Render rebuild now carries FOUR importer decisions
Section titled “2026-08-12 — The pending Render rebuild now carries FOUR importer decisions”The “one rebuild covers D137 + D142” entry above is superseded: the same single rebuild of
skyfall-keru.onrender.com now also materializes:
- D158 — Bondlayer’s “Filters Selection Mode” (
targetFilterSelection) maps todata-mf-mode(single→set,multi→multi), so the 37 imported filter buttons across the captures gain real activation modes on re-import. - D159 — page roots get the framework
height:100%stamp, which is what makes authoredheight:100%sections, internal scrollports and STICKY navigation actually work on imported pages (35 + 17 roots across 12x12/simaomartinez; 582 + 180 position layers ride on it).
Order stays: rebuild Render → re-import BOTH captures (12x12 and simaomartinez) → run trimscan on 12x12 → republish. The D159 serializer/canvas halves (border-box, body height chain) are already live for every site at its next publish/editor load and need no re-import — but note the per-site rollout window recorded in the D159 ledger entry: canvas and published page can disagree on percentage-height roots until that site republishes.
2026-08-13 — The same pending Render rebuild also carries D160 (unmapped filter conditions)
Section titled “2026-08-13 — The same pending Render rebuild also carries D160 (unmapped filter conditions)”- D160 — unmapped Bondlayer filter/conditional conditions now DROP with a lossy-import
warning instead of passing through verbatim into the query engine’s
default: return true(match-everything). Concretely for 12x12: the fivetext-search-unsafepre-filters on the user/artist search repeaters stop silently widening those lists; on re-import they render unfiltered (Bondlayer’s own empty-search-box state) and the import report says so (runtime_search_filter:text-search-unsafe: 5).
2026-08-13 — …and D162 + D163 (operator completions, live search wiring); one REPUBLISH-only half
Section titled “2026-08-13 — …and D162 + D163 (operator completions, live search wiring); one REPUBLISH-only half”- D162 —
not-contains/not-contained/datetime-isSame/datetime-isBetweennow map to real engine operators instead of dropping, and the inclusivedatetime-isSameOrAfter/isSameOrBeforebecome exactly faithful (theinclusive_date_filterwarning class is retired — 6 occurrences in 12x12 gain their boundary day on re-import). - D163 — the Catalogo-2024 user search box imports as a LIVE filter input (type to narrow the list on the published page); the four PROFILE search boxes stay inert with a warning because they are already broken in the Bondlayer source (their forms target orphan repeaters).
- Two halves, two triggers: the importer halves need the same pending Render rebuild +
re-import as D137–D161. But D163’s serializer fix (underscored
data-mf-*item markers no longer dropped — before it, ACTIVATING any of 64/67 imported filter/sort buttons on 12x12 hid every row) ships with the Worker deploy and reaches each already-imported site at its next REPUBLISH — worth republishing even before the rebuild/re-import happens.
2026-08-13 — …and D166 (filter-element boolean coercion); ledger row 18 verifies after re-import
Section titled “2026-08-13 — …and D166 (filter-element boolean coercion); ledger row 18 verifies after re-import”- D166 (#462) — imported filter buttons stop comparing boolean columns against leftover
record ids (
eq "neaBz0Ouz3"→eq true), clear buttons stop emitting the literal"{}", and no control renders its machinevalueas its visible caption. Invisible until the same pending Render rebuild + re-import as everything since D137. - After that re-import, check coverage-ledger row 18 (BONDLAYER-FOLLOWUPS-WORKSTREAM): the artwork grid should show exactly 3 items (the monochrome pre-filter, faithfully), the series list 1, and the exhibitions slider stays empty because no exhibition row carries an end date — that last one is faithful-by-data, not a bug, unless the live Bondlayer site disagrees.
2026-08-13 — …and D167 (sentinel filter controls go inert); same rebuild, one visible change
Section titled “2026-08-13 — …and D167 (sentinel filter controls go inert); same rebuild, one visible change”- D167 (#463) — 31 imported filter buttons that compared against Bondlayer runtime sentinels
(
_current,_user) no longer emit a predicate: clicking one can no longer empty its list. - Visible change to expect after the re-import: those buttons become DECORATIVE (they render
and can be styled/pressed, but filter nothing) rather than list-destroying. That is the honest
end state until the client runtime gains current-item/member context — tracked as coverage-
ledger row 20. The import report now names them (
filter_control_runtime_sentinel:_current/:_user), so the count is visible at import time rather than discovered by a visitor.
2026-08-21 — D:filter-context (S15a): 25 of those 31 buttons come back — NEEDS a Render rebuild + re-import
Section titled “2026-08-21 — D:filter-context (S15a): 25 of those 31 buttons come back — NEEDS a Render rebuild + re-import”- What changed. A Bondlayer “★ Current Item” filter comparand (
_current,_current:<attr>) is now re-emitted by the importer as a CONTEXT predicate (data-mf-context*) and SUBSTITUTED at compile time by the shared filter-control expander: on a detail page the control becomes an ordinary literal filter against the current item’s real value._current:multiRef_*becomes a value LIST under the existinginoperator. - Visible change to expect after the re-import: on DETAIL pages, 25 of the 31
buttons D167 made decorative start filtering again (a card filtering a sibling
list by its own record — “more from this series”, “works by these artists”).
The other 6 stay decorative in this slice: the 4
_user(signed-in-member) heads, which S15b’s identity runtime came back for a few hours later (see the next entry), and 2 whose target attribute is a multiReference — Matriz stamps no item-side marker for an array field, so a predicate there could only empty the list. The import report prints the conversions as a WIN line (Current-item filter comparands → context predicates: 25 (_current×25)) and still names the 6 in the lossy block. - Two deploys, and only one of them is the Worker. The substitution half is
in
@matriz/site-render, so it ships with the ordinary Worker/editor deploy and reaches every site at its next REPUBLISH — but nothing on an existing site carriesdata-mf-contextyet, so nothing changes untilapps/bondlayer-worker/is rebuilt on Render and the site is re-imported. - Safety note, so it is on the record: context filters are UX, never access control. The substituted value comes from the item the detail page already renders, and the filter only hides rows already present in the published HTML. Nothing new ships to a visitor, and no gate is involved.
2026-08-21 — D:filter-context-member (S15b): the last 4 of those buttons come back — NEEDS a Render rebuild + re-import
Section titled “2026-08-21 — D:filter-context-member (S15b): the last 4 of those buttons come back — NEEDS a Render rebuild + re-import”- What changed. The other Bondlayer sentinel,
_user(“the signed-in user”), is now re-emitted asdata-mf-context="member"and resolved IN THE BROWSER: the published page’s own script makes ONE same-origin request to/api/m/<targetId>/members/context, which answers{id, slug, role}for a signed-in member and an anonymous marker otherwise, then arms the control. - Visible change to expect after the re-import: on the 12x12 site, the 4
“show my row” buttons work for a SIGNED-IN member and are HIDDEN for
everyone else. Hidden is the resting state, so an anonymous visitor, a
network failure, or a site with member sessions switched off all look the
same — no dead button. A theme can restyle the three states via
[data-mf-context-state="pending|member|anon"]. - What does NOT happen: the member’s id never enters the URL (a member filter is deliberately not shareable), and no member field value — email, name, profile — is ever on that wire. Nothing about page gating changes; a members-only page is still gated at the edge, exactly as before.
- The published script’s sha256 rotated for every filter page (the runtime
gained the member pass). That is automatic — the CSP hash is derived from the
bytes at publish — but it does mean every site’s next republish emits a new
script-src 'sha256-…'.script-srcis still'none'or hashes, neverunsafe-inline. - Two deploys again, same shape as S15a: the runtime + endpoint ship with
the ordinary Worker/editor deploy and reach every site at its next
REPUBLISH; nothing carries
data-mf-context="member"untilapps/bondlayer-worker/is rebuilt on Render and the site is re-imported.MEMBER_SESSION_SIGNING_KEYis already set on prodskyfall-api; without it every visitor reads as anonymous and every one of these controls stays hidden. - The 2 multiReference-target refusals still stand — Matriz stamps no item-side marker for an array field, so a predicate there could only empty the list. Corpus total: 29 of 31 restored.
- Safety note, on the record: context filters are UX, never access control. The endpoint hands a caller only their OWN identity, and the filter only hides rows already present in the published HTML.
2026-08-14 — The re-import runbook, in order (two independent deploys, then one import)
Section titled “2026-08-14 — The re-import runbook, in order (two independent deploys, then one import)”Both prerequisites are now merged, and they ship on DIFFERENT pipelines — the reason this needs stating once rather than being inferred from the two entries above.
- Worker deploy — D169 (automatic). Cloudflare Workers Builds deploys
skyfall-apion every push tomaster, so the content-item id scoping is live as soon as its build goes green. Nothing to do; verify only if an import misbehaves. - Render rebuild — D137 + D142 + D158 + D159 + D174 + D177 (manual, still pending).
skyfall-keru.onrender.comdeploys from its own pipeline and does NOT follow master. Until you rebuild it, the deployed Python importer is the old one: backgrounds/sliders, duplicate-id dedupe, Bondlayer filter activation modes, the page-root height stamp that makes sticky navigation work, and the D174 font pipeline (font-display per file + family-quoting byte-identity) are all absent from whatever it emits. ↳ 2026-09-02 (D:importer-containers): the Render rebuild this asks for is superseded once the container cutover below runs — after Pass E,apps/bondlayer-worker/merges deploy thematriz-import-runtimecontainer automatically and the Render service is decommissioned (Pass F). Until the flip, this action stands. See § 2026-09-02 at the end of this file. - Then re-import, into a FRESH target (the wizard does this anyway; re-importing into an already-imported target still skips rather than updates — D169’s coverage-ledger row 21, a product call left open deliberately).
- Then trimscan on 12x12, then republish.
Order matters in one direction only: step 1 must precede step 3, or the import writes zero content items again. Step 2 can happen any time before step 3 — but if you skip it, the import succeeds and silently lacks four decisions’ worth of fidelity, which is much harder to notice than an empty CMS.
What each step is verifiable by: after (3), the target’s collections show non-zero item counts
(the D169 symptom was 16 collections / 0 items). After (2)+(3), an imported page with a sticky nav
actually pins, and imported filter buttons carry data-mf-mode.
2026-08-14 — Archive-index compile container: a seam is built, nothing is provisioned (D:archive-compile)
Section titled “2026-08-14 — Archive-index compile container: a seam is built, nothing is provisioned (D:archive-compile)”- What shipped: the B+ archive tier’s publish half (FILTER-INTERFACE-DESIGN §9b). Publish now
compiles static index chunks for any visitor-filterable list that exceeds its rendered cap, and
writes them under
published/<targetId>/_archive/<listKey>/. Below the cap nothing is written and the page is byte-identical to before, so no existing site changes until a list actually outgrows its cap. - What is NOT provisioned — the escalation: §9b calls for the index build to run in a compute
container “from v1”, because the publish Worker’s CPU limit is the wrong ceiling for
archive-scale lists. The container’s code path exists and is tested
(
apps/compile-worker/, sharingworkers/src/lib/archive-compile*.tsverbatim — no fork), and the Worker’s dispatch seam exists (ARCHIVE_COMPILE_URL+ARCHIVE_COMPILE_TOKEN). No infrastructure was created, no secret was set, nothing was deployed. This run does not provision infrastructure. - What happens meanwhile (no action required for it to be correct):
- ≤ 1 400 items beyond the cap → compiled in the Worker (measured fast path).
- 1 400 – 2 340 → still compiled in the Worker, with a warning logged once per publish.
- > 2 340 items beyond the cap → the list is skipped: the page publishes normally with
capped rows and rendered-only filtering, and the publish log says why. Nothing breaks; that
list just has no archive index until the container exists. The ceiling is the tighter of a
CPU-derived bound (5 600 = measured 700 items/s against the Worker’s 30 s CPU limit) and a
MEMORY-derived bound (2 340 = measured ~28 KiB heap/item against half the 128 MB isolate —
memory binds; the review measured 5 600 items at ~153 MB) — see
docs/architecture/ARCHIVE-INDEX-COMPILATION.md§4.
- Owner action to activate it (only worth doing when a real site has a list above ~2 340
beyond-cap items — until then the Worker path is strictly simpler):
- Pick a host. Either is fine and neither is better on the merits today:
- Render — same pipeline shape as
apps/bondlayer-worker; a Docker web service fromapps/compile-worker/Dockerfilewith the repo root as build context. Cheapest to reason about because the pattern is already in this repo. - Cloudflare Containers — keeps the traffic inside Cloudflare (no public hop, lower latency from the Worker) at the cost of a deploy path this repo has never used.
- Render — same pipeline shape as
- Build the image once, locally, before wiring anything:
docker build -f apps/compile-worker/Dockerfile -t matriz-compile-worker .— the esbuild bundle step is verified in-repo, but the image itself has never been built in CI, so expect to iterate on the Dockerfile the first time. - Generate a token (any 32+ random bytes) and set it on BOTH sides:
- service env:
ARCHIVE_COMPILE_TOKEN=<token>(the service refuses every request without it — a compile endpoint open to the internet is a CPU faucet); - Worker:
wrangler secret put ARCHIVE_COMPILE_TOKEN --cwd workers.
- service env:
- Point the Worker at it: add
"ARCHIVE_COMPILE_URL": "https://<service-host>"tovarsinworkers/wrangler.jsonc(not sensitive — it is an origin, mirroringIMPORT_WORKER_ORIGIN) and deploy. The Worker appends/compile. - Verify:
curl https://<service-host>/health→ok, then republish a site with a beyond-cap list and confirm the publish response carriesarchive.executed.container: 1. Any failure falls back to in-Worker compilation with a logged warning, so a bad URL degrades performance, never correctness.
- Pick a host. Either is fine and neither is better on the merits today:
- Redeploy trigger:
apps/compile-worker/shares its source withworkers/src/lib/, so once the service exists a change underworkers/src/lib/archive-compile*.tsrequires rebuilding the container too — the same standing hazard as the Render importer image. Until it exists, there is nothing to keep in sync.
2026-08-15 — assets.metadata.public is SET-ONLY, and the archive tier widens its blast radius (D:archive-compile review F10 — PROPOSAL, nothing executed)
Section titled “2026-08-15 — assets.metadata.public is SET-ONLY, and the archive tier widens its blast radius (D:archive-compile review F10 — PROPOSAL, nothing executed)”- The mechanism. Publish flags every asset a public page references with
metadata.public = true(a jsonb merge inroutes/publish.tsstep 4b), which makes the object servable anonymously at/api/asset-pub/…. Nothing ever CLEARS the flag: an asset that stops being referenced — the page is deleted, the image swapped, the list unpublished — stays world-readable forever. This predates the archive tier, but the tier changes the scale: archived rows’ images join the public set too, so a site whose beyond-cap rows carry images can go from ~12 flagged assets to ~400 in one publish. Every one of them is a one-way door today. - Why it was NOT “fixed” in the archive branch. The naive fix — after each publish, clear
publicon every project asset not in this build’s referenced set — is WRONG and dangerous: assets are project-scoped and shared across a project’s targets, so target A’s publish would un-flag (break) images that target B’s live pages still reference. Any correct fix needs the union of referenced keys across all of a project’s live targets, which is either a data migration or a cross-target reconciliation pass — a policy call, not a drive-by. - Proposal (pick one, owner decision):
- Per-target provenance + union reconcile (recommended). Store each build’s referenced
public keys where they already naturally live —
builds.build_config(the latest successful build per target is authoritative). At the end of each publish, compute the union ofpublicKeysacross every target’s latest successful build in the project, then clearmetadata.publicon project assets outside that union (and same formemberGate). One query over data publish already holds; no schema change; converges within one publish per target. Cost:build_configgrows by one key list (~bounded by the flag count itself). - Expiring flags. Stamp
metadata.publicAsOf = <buildId>instead of a boolean and have/api/asset-pubaccept only assets whose stamp is within the project’s live build set. Cleaner semantics, but a serve-path DB dependency the anonymous asset route was explicitly built to avoid. - Accept and document. Treat
publicas “was ever published” — the current de-facto semantics. Zero work, but the flag keeps monotonically widening, and a tenant who unpublishes a sensitive image has no way to make it private again short of deleting the asset. Not recommended once archives multiply the flag count.
- Per-target provenance + union reconcile (recommended). Store each build’s referenced
public keys where they already naturally live —
- Until decided: nothing in the archive tier makes a PRIVATE asset public — only assets actually referenced by public pages or their beyond-cap rows are flagged, which is the same contract page docs have always had. The problem is exit, not entry.
2026-08-21 — Imported-sites fidelity batch (branch claude/imported-sites-fidelity-kn6maj): what re-materializes where
Section titled “2026-08-21 — Imported-sites fidelity batch (branch claude/imported-sites-fidelity-kn6maj): what re-materializes where”- What shipped (see the branch/PR for detail): reference hops (
via) survive binding normalization and resolve on the importer’sslot_bindings/style_bindingsshapes (the “same-item reference” defect);reference_collectionis re-scoped with content-type ids; imported breakpoint ids are style-layer keys again (emitter fix + ingest-time repair), which is what made the gap/columns controls dead on imported sites; target delete honours the verified org claim, verifies its rowcount, and the dashboard surfaces delete failures; the wizard’s “already imported” receipt no longer false-positives on every first import (provisioning-seeded tokens/pages are out of theisReimportdecision); the wizard shows per-asset download counts + failures; the canvas no longer paints authored backgrounds across the 60px empty-element fallback (the “grey band” affordance lie). - Ops to materialize on the imported sites (unchanged queue, same one rebuild covers all):
(1) Render image rebuild of
apps/bondlayer-worker— the breakpoint-id emitter fix rides it, though ingest repairs the shape either way; (2) re-import the Bondlayer projects — the hop/reference_collection/breakpoint repairs apply at ingest, so a re-import through the DEPLOYED Worker (auto-deploys on merge) heals stored data even before the Render rebuild; (3) republish. Deleting the old targets first now actually works (or says why it didn’t). - Still open here:
assets.target_id ON DELETE SET NULL(the 2026-08-10 owner decision on target- vs project-owned media) is untouched — deleted imports still leave their asset rows and R2 bytes in the project library, which is why a fresh import into the same project lists the old media and skips re-downloading identical bytes (that skip is by design).
2026-08-21 addendum — member-satellite galleries + geo columns (same Render rebuild)
Section titled “2026-08-21 addendum — member-satellite galleries + geo columns (same Render rebuild)”- An xlsx↔payload audit of both captures found the member-private exclusion was importing
12x12’s
users_folders— 331 participant galleries, ~2,900 artwork images the site publishes — as NOTHING. Under the wizard’s existing “Import members” opt-in (S11), member SATELLITE collections now absorb into the public CMS as well (the users sheet itself and favorites never do, with or without consent); without the opt-in the CLI prints a pointer instead of silence.geo_*xlsx columns (venue/location maps, both captures) also import now instead of being silently unrecognized. Both ride the SAME pending Render rebuild; tick “Import members” on the 12x12 re-import to get the galleries.
2026-08-22 — Docs sites need their Cloudflare Pages custom domains (D:doc-reset)
Section titled “2026-08-22 — Docs sites need their Cloudflare Pages custom domains (D:doc-reset)”- Blocker: the doc reset added two Astro Starlight sites —
apps/docs-product(docs.matriz.design) andapps/docs-dev(dev.matriz.design) — with deploy workflows (deploy-docs-product.yml/deploy-docs-dev.yml, gated on the existingCLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_IDrepo secrets). The workflows create the Pages projects (matriz-docs,matriz-docs-dev) on first deploy, but attaching custom domains cannot be done from CI with the current tooling, and this session has no Cloudflare dashboard/API access. - Done instead: both sites build green locally; the dev site regenerates its content from
repo markdown on every build (manifest in
apps/docs-dev/scripts/sync-content.mjs). - Owner action: after the branch merges and the two workflows have run once — in the
Cloudflare dashboard, Pages →
matriz-docs→ Custom domains → adddocs.matriz.design, and Pages →matriz-docs-dev→ Custom domains → adddev.matriz.design(both create the needed DNS records automatically since matriz.design is on this account). Decide whetherdev.matriz.designshould sit behind Cloudflare Access (it publishes internal engineering docs; the repo is private — the site would otherwise be public).
2026-08-23 — Imported brand colors were never applied; the queued re-import is the fix (D:import-color-role-repoint)
Section titled “2026-08-23 — Imported brand colors were never applied; the queued re-import is the fix (D:import-color-role-repoint)”- What was wrong:
applyImportedColorRoleshas been a no-op since it was written, so every Bondlayer-imported site renders three of its five brand colors (primary / secondary / accent) as the preset gray — in the editor preview AND in published output.light/darkwere unaffected (nothing seeds a competitor for them, so they resolve off the literal fallback), which is why imports looked partly right and this went unnoticed. Root cause and fix: PR #571 — the two sides spell the role differently, so the repoint read and wrote the same row instead of repointing the seeded one. - Owner action — none beyond the Render rebuild + re-import already queued above. A re-import IS the remediation: the seeded token is still sitting at its preset default on every affected site, so the corrected repoint fires on the next import of that target. No data migration was written, and on this reading none is needed.
- How to verify after the re-import: open the site’s design tokens and confirm
primary/secondary/accentcarry the site’s real palette rather than a gray, or inspect a published page’s:rootfor--color-primary. If a target was hand-customized after import, the repoint deliberately leaves it alone (same guarantee as the font roles) — that target needs a manual token edit instead. - Separate, NOT fixed, flagged for a decision: imported design tokens are persisted with
a NULL
reference_path, and the renderer’s:rootemitter keys offreference_path— so imported token rows emit no CSS variable of their own at all. Today that is masked (the three roles above are handled by the repoint; the rest resolve off literal fallbacks), but it means--color-light,--color-dark,--font-primaryand--size-*exist as rows no renderer ever emits. Fixing it changes what every imported site’s:rootcontains, so it wants its own decision rather than a drive-by.
2026-08-24 — Target/project deletion completeness: the open decision is CLOSED, and a hard bug was found under it
Section titled “2026-08-24 — Target/project deletion completeness: the open decision is CLOSED, and a hard bug was found under it”Answers the 2026-08-10 entry “Deleting a target abandons its whole media catalog, in Postgres AND
R2 — owner decision needed”, now archived
because this entry closed it. The owner’s instruction was that deleting a target or a
project must delete the entire thing, precisely, so option 2 is taken: target ownership of media
is real — deleting a site deletes its images, with the one refinement that an asset a SURVIVING
sibling still renders is kept and demoted to the project library (the reference scan in
workers/src/lib/delete-cascade.ts, which fails closed). Implemented, tested against real Postgres
(PGlite), D:delete-completeness.
The find that was not in the 2026-08-10 audit: every project was UNDELETABLE.
projects cascades to both targets and design_systems, but targets.design_system_id → design_systems.id has no ON DELETE action, so the project cascade aborted with
update or delete on table "design_systems" violates foreign key constraint"sites_design_system_id_design_systems_id_fk" on table "targets"Provisioning creates a design system per target, so this fired for effectively every project.
org.destroy() succeeded in Clerk while /api/webhooks/clerk 500’d, Svix retried forever, and the
customer’s whole row tree stayed live behind an org that no longer existed — with its media already
purged one line earlier, so the surviving rows point at bytes that are gone. A permanently-500ing
delivery is also exactly what disables a Svix endpoint, which is worth holding next to the
2026-08-21 webhook outage. Fixed in code (targets are deleted before the project row); no schema
change was needed for it.
Owner actions — three, all optional, none blocking:
-
Reconcile any zombie projects. If a project was deleted in Clerk before today, its
projectsrow and everything under it are probably still in Neon while its<projectId>/media is gone. They are invisible in the dashboard (no Clerk org ⇒ no membership ⇒ no listing). Find them by diffingSELECT id, name FROM projectsagainst the org list in the Clerk dashboard; the remainder can be removed by re-firingorganization.deletedfor each id, or by deleting the target rows and then the project row in that order. -
Sweep the existing residue.
POST /api/projects/:projectId/orphans/sweep(admin, dry run unlessapply=1) reports and then removes:abandoned-prefixes(R2 underpublished/<id>/,builds/<id>/,published-versions/<id>/,backups/<id>/,redirects/<id>.jsonfor target ids with no row anywhere — this is where the deleted targets’ published output still sits),orphan-objects(objects under<projectId>/no asset row names),abandoned-yjs-snapshots(opt-in by name —snapshots/<targetId>in the SEPARATEYJS_SNAPSHOTSbucket for target ids with no row; added 2026-09-02, this was the one residue class NOTHING could previously reach, since every other kind listsASSETS_BUCKETand the cascade needs a target row that is gone), and — opt-in by name, age-gated at 7 days —unreferenced-assets(the 5,906 detached rows, minus anything a live page still renders). Run the dry run first and readsample_keys;truncated: truemeans run it again. Attribution is NOT recoverable: withtarget_idNULL and the target gone, an import orphan and a deliberate library upload are the same row, so the sweep only ever asks “is anything that still exists pointing at this”. -
Wipe the imported sites (added 2026-09-02, for “re-run the import from scratch”).
POST /api/projects/:projectId/imports/purge(admin, dry run unlessapply=1) deletes IMPORTED targets whole — rows, published HTML, build artifacts, Y.Doc snapshot, owned media, custom hostnames — by routing each one through the samedeleteTargetCompletelycascade asDELETE /api/targets/:id. Order matters: run this BEFORE the sweep in (2), because it turns live imported sites into exactly the residue the sweep collects.Use the ops token, not a Clerk JWT. Both operations are chunked — you re-invoke while
truncatedis true — and a Clerk session token expires in about a minute, so it cannot drive them from a script.OPS_ADMIN_TOKENis a shared-secret bearer on a parallel path (/api/ops/...), the same patternIMPORT_WORKER_TOKENalready uses for the Render service. Set it once; rotate by putting a new value (nothing is stored in the DB to revoke):Terminal window # One-time. NEVER a plaintext var — a var shadows a same-named secret.openssl rand -base64 32 # use the output as the value# `wrangler` is a devDependency of workers/ — NOT global and NOT a root dep. If# `wrangler` is not on your PATH, run it through pnpm and DROP `--cwd workers`# (pnpm already puts you in workers/, so keeping it looks for workers/workers/):pnpm --filter ./workers exec wrangler secret put OPS_ADMIN_TOKEN# Or skip the CLI entirely: Cloudflare dashboard → Workers & Pages → skyfall-api# → Settings → Variables and Secrets → Add → type "Secret".Terminal window # 1. Dry run — names every site that would be destroyed, deletes nothing.curl -X POST -H "Authorization: Bearer $OPS_ADMIN_TOKEN" \"https://api.matriz.design/api/ops/projects/$PROJECT_ID/imports/purge"# 2. Read the `targets[]` list. Then, and only then:curl -X POST -H "Authorization: Bearer $OPS_ADMIN_TOKEN" \"https://api.matriz.design/api/ops/projects/$PROJECT_ID/imports/purge?apply=1"# `truncated: true` ⇒ run it again; chunks are resumable and idempotent.# 3. Then collect the residue, including the Yjs bucket:curl -X POST -H "Authorization: Bearer $OPS_ADMIN_TOKEN" \"https://api.matriz.design/api/ops/projects/$PROJECT_ID/orphans/sweep?kinds=abandoned-prefixes,orphan-objects,abandoned-yjs-snapshots&apply=1"The same two operations stay mounted under
/api/projects/...behind Clerk +requireCapability('admin')for use from a signed-in session; both doors run the SAME handlers (workers/src/lib/cleanup-handlers.ts), so they cannot drift. Absent secret ⇒ the ops door 503s and only the Clerk door works. The token authenticates and does not widen scope: both handlers still act inside the:projectIdin the path, and both runners are still dry-run withoutapply=1. Do not mount anything else on that router — see the header ofroutes/ops.ts.⚠️ A LOW
matchedDOES NOT MEAN THERE IS LITTLE TO PURGE. This is the trap in this runbook, and it is a deploy problem, not a design one. Two things provision an imported target:ImportWizard.tsxin the browser, which sendsimportSourcefor thesite-json/bondlayeradapters, andapi_writer.pyon the Render worker, which did not send it until 2026-09-01 (commitcb60dd1, a one-line change). That commit lives inapps/bondlayer-worker/, and per § 2026-08-14 the Render image has never been rebuilt —IMPORT_RUNTIMEis still"render". So the tagging code is merged and NOT RUNNING, and every target the deployed importer ever provisioned carriesimport_source = NULL. The purge’s default selector will not see them.matched: 0can mean “nothing is labelled”, not “nothing is there”. The responsenotesays so on every call, including errors.Work around it by naming them: read the site ids out of the dashboard (the editor URL is
/editor/<targetId>) and pass&target_ids=<uuid>,<uuid>. The dry run echoes each id’s name and slug, so you confirm you named the right sites before applying. The other wizard adapters (webflow, wordpress, csv, standalone xlsx, json) areNULLfor the same reason and have never been run as of 2026-09-02.⚠️ Base design systems and their tokens are never purged (owner instruction, 2026-09-02: import data only) — but as of
D:design-system-reusethat is enforced by the ROW, not by a flag. A project’s shared brand carriesdesign_systems.is_base; an import’s private system, which is the import’s own output surface, never does. The purge now passes each target’s system todeleteOrphanedDesignSystem, which refuses a base system outright and refuses any system a surviving target still references, and collects only the private per-import ones.&purge_design_systems=1is gone and passing it does nothing.designSystemsDeletedcounts what was collected;designSystemsKeptcounts purged targets whose system survived. -
Apply the FK sidecar
db/migrations/20260824_drop_legacy_target_project_fk.sql(NOT applied). It drops the legacysites_organization_id_organizations_id_fk(ON DELETE SET NULL) flagged on 2026-08-10 — the duplicate that can leave a target alive withproject_id = NULL, outside every tenant guard. The code path no longer reaches that hazard (targets are deleted explicitly first), so this is defence in depth. The file carries its own verify-first queries.
Deliberately left as-is: audit_events.target_id stays SET NULL — an audit event is a record
about the target, outlives it by design and dies with the project. Analytics Engine page views
are append-only and cannot be deleted per target. targets.design_system_id stays NO ACTION;
changing it to SET NULL would be the cleaner DB-level fix and is a reasonable follow-up, but it
needs a journaled migration and the code fix removes the failure today.
2026-09-02 — Importer → Cloudflare Containers: everything is built, the cutover is yours (D:importer-containers)
Section titled “2026-09-02 — Importer → Cloudflare Containers: everything is built, the cutover is yours (D:importer-containers)”Spec: IMPORTER-CONTAINERS-MIGRATION-SPEC.md. Passes A1–A3,
B1, C1 and D landed on claude/container-migration-spec-2ilvjr; nothing below has run. Today’s
traffic is unchanged: IMPORT_RUNTIME is "render" in workers/wrangler.jsonc, the three
skyfall-keru.onrender.com vars are untouched, and with the var unset every pre-existing proxy /
materialize / trimscan test passes unmodified. Rollback at every step below is flipping that one var
back — never a redeploy of the container (invariant I5).
- What exists now:
apps/bondlayer-worker/edge/(Workermatriz-import-runtime, container classImportRuntimeContainer, bindingIMPORT_CONTAINER,instance_type: basic, one singleton instance,sleepAfter15 min, an edge gate that refuses anything withoutX-Import-Worker-AuthexceptGET /healthand job-token polls); the resolverworkers/src/lib/import-runtime.ts(sole reader of the importer origin vars;IMPORT_RUNTIME+IMPORT_CONTAINER_ORIGINadded toskyfall-api);.github/workflows/deploy-import-runtime.yml(path-filtered toapps/bondlayer-worker/**+ manual dispatch);importer-ci.ymlnow also builds the Docker image and boots it (/health), and typechecks/lints/tests the edge package; the parity gateapps/bondlayer-worker/tools/parity_check.py(verified this session against a localuvicorn main:appwith the real 12x12 capture — async vs sync runs comparestructurally-identical; the only differences are per-run uuid4 ids, which it renumbers by first appearance and lists). - What could NOT be verified here: the Docker image build (no daemon in the agent sandbox —
wrangler deploy --dry-runvalidated the containers block up to the point where it needs Docker, and--containers-rollout=noneconfirmed the config + bundle); anything against live Cloudflare (no Containers deploy exists yet); the container’s real cold-start time.
Pass E — deploy + cutover (owner-paced; an agent can drive each step, you verify against live systems):
- Preflight. Workers paid plan active (Containers requirement). The
CLOUDFLARE_API_TOKENrepo secret must carry Workers Scripts:Edit + Containers (Cloudchamber):Edit on top of whatdeploy-api.ymlneeds —wrangler deploybuilds the image on the runner and pushes it to Cloudflare’s managed registry with that token. Mint or reuse an R2 S3 credential pair forskyfall-assets(the Render one works; rotating at cutover is the better hygiene). - Secrets on
matriz-import-runtime(nevervars):for s in IMPORT_WORKER_TOKEN R2_ACCOUNT_ID R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY; do wrangler secret put "$s" --cwd apps/bondlayer-worker/edge; done—IMPORT_WORKER_TOKENis the SAME valueskyfall-apiholds. Optional:R2_BUCKET_NAME(defaultskyfall-assets),R2_S3_ENDPOINT. Do not setALLOWED_ORIGINS. - First deploy:
workflow_dispatchondeploy-import-runtime.yml(orpnpm --filter matriz-import-runtime deployfrom a machine with Docker). Verifypython3 apps/bondlayer-worker/tools/parity_check.py health --origin https://matriz-import-runtime.<subdomain>.workers.dev→ 200. Verify an unauthenticatedPOST /importgets a 401 from the edge without a container wake (Workers Logs onmatriz-import-runtime). - Parity gate:
parity_check.py runwith--fixture-dir imports/12x12once againsthttps://skyfall-keru.onrender.comand once against the container origin, thencompare— must printbyte-identicalorstructurally-identical(exit 0). Then one/materializeand one/trimscanround-trip on a scratch target: the finalize callbacks arrive at/api/import/finalize-assets/:id//api/import/finalize-font-metrics/:id, and R2 objects land under the project prefix. - Cold start: wait out
sleepAfter(15 min idle) or stop the instance, then timeparity_check.py healthand a smallrun. Record the numbers here. If health takes more than ~10 s, reconsidersleepAfteror a warming ping before flipping. - Flip: in
workers/wrangler.jsoncsetIMPORT_CONTAINER_ORIGINto the Worker origin andIMPORT_RUNTIMEto"container", merge (Workers Builds deploysskyfall-api), run one real wizard import end-to-end. Rollback =IMPORT_RUNTIME: "render", merge, done. - Soak two weeks with Render kept warm. Watch Workers Logs on both Workers, wizard import
success, materialize/trimscan finalize callbacks, and the container’s wake/billing pattern.
Opportunistically record
/materializeRSS from container metrics — that number decides whetherinstance_typecan drop frombasic.
Pass F — decommission (after the soak): retire the three skyfall-keru.onrender.com vars from
workers/wrangler.jsonc (recommend KEEPING the IMPORT_RUNTIME seam pointing only at the container
— dual-run stays free); suspend the Render service, delete it after one more cycle; rotate
IMPORT_WORKER_TOKEN (both Workers) and the R2 credentials Render held; mark the pending
Render-rebuild actions above superseded; update the tracker’s agent_work_state.
Accepted risks, unchanged by this entry: the in-memory JobStore is lost when the container
sleeps (same class as a Render restart; externalizing it is spec §6 follow-up work), and the first
import after ≥15 min idle pays a cold start that Render’s always-warm service never did.
2026-09-02 — Importer container: Pass E executed, cutover flipped (D:importer-containers)
Section titled “2026-09-02 — Importer container: Pass E executed, cutover flipped (D:importer-containers)”Pass E from the entry above, as actually run (owner + agent, the same day #651 merged):
- Deploy: the first
deploy-import-runtime.ymlrun on the merge commit built the image on the runner, pushed it to Cloudflare’s managed registry and created the container application (basic,max_instances: 1). The repo’s existingCLOUDFLARE_API_TOKENalready carried the Containers scope — no token change was needed. Worker origin:https://matriz-import-runtime.simaomartinez.workers.dev. - Secrets: all six set on
matriz-import-runtime(IMPORT_WORKER_TOKEN,R2_ACCOUNT_ID,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY,R2_BUCKET_NAME,R2_S3_ENDPOINT— Render sets the last two, so they were mirrored rather than left to the code defaults). Set BEFORE the first wake, so the container’s first start saw its full environment. - Gate: before secrets,
GET /answered 503 (fail closed, container untouched); after, 401. Neither started the container. - Cold start: first
GET /health= 1.76 s wall time at the Worker, container start + uvicorn boot included — far under the ~10 s that would have argued for a warming ping. A warm/healthanswers in ~50 ms. (Workers Logs also show a long-lived Durable Object event per container request — 165 s on the warm health call — that is the container library’s background monitor, not client latency; the stateless side of the same trace answered in 48 ms.) - Parity gate:
parity_check.pyon the 12x12 capture through both hosts —structurally-identical(only per-run uuid4 ids differ). Same 9,500,372-byte Site JSON, 35 pages / 10,351 nodes / 54 content types / 41 fonts / 100 assets from both. Wall clock: Render 44.6 s (14.1 s just to accept the POST), container 10.3 s. - Not exercised before the flip: a
/materialize+/trimscanround-trip. Both need a real target and a real import to drive them; the first wizard import after the flip is that test. Both keep their fallbacks (in-Worker materialization; browser-side metrics backfill), so a callback failure degrades, never breaks. Verify on the first import: the/api/import/finalize-assets/:idand/api/import/finalize-font-metrics/:idcallbacks land (skyfall-api Workers Logs) and objects appear in R2 under the project prefix.
Flip: IMPORT_RUNTIME: "container" + IMPORT_CONTAINER_ORIGIN set in workers/wrangler.jsonc.
Rollback = IMPORT_RUNTIME: "render" in the same file, merged.
Soak (two weeks from the flip, Render kept warm): wizard imports succeed; finalize callbacks
arrive; Workers Logs on both Workers stay clean; note the container wake pattern and, if visible,
/materialize memory — that number decides whether instance_type can drop below basic. Then
Pass F (previous entry).
First real import after the flip (13:59 UTC, same day) — one defect found and fixed. The import
itself ran on the container (POST /import 202, 25 job polls, ingest 200). But the container edge
gate answered 401 to POST /materialize and POST /trimscan: those two dispatches carry the
shared secret only as the finalize_token body field, and the gate admits on the
X-Import-Worker-Auth header alone (it never reads bodies). Exactly as designed, both degraded
instead of failing — materialize ran the in-Worker CF Image Resizing pass, trimscan no-op’d to the
browser-side backfill — so the import completed with assets, just not via the container. Fix:
skyfall-api now sends the secret as the header too on both dispatches (Render ignores it; FastAPI
keeps checking the body field). Pinned in import-finalize-dispatch.test.ts and trimscan.test.ts.
Verify on the next import after that fix deploys: POST /materialize / POST /trimscan answer
200 on matriz-import-runtime, and the two finalize callbacks land on skyfall-api.
2026-09-02 — OPEN: verify #656 on the next import, then the container soak (D:importer-containers)
Section titled “2026-09-02 — OPEN: verify #656 on the next import, then the container soak (D:importer-containers)”Do not lose this stream. #656 (merged 17:30 UTC) makes skyfall-api send X-Import-Worker-Auth
on the /materialize and /trimscan dispatches so the container edge stops answering 401. It has
NOT yet been proven on a real import. Owner checklist, in order:
- Run one wizard import (any capture with images + custom fonts) after the
skyfall-apibuild for the #656 merge commit is live (Workers Builds). - On
matriz-import-runtimeWorkers Logs:POST /materializeandPOST /trimscanmust be 200 (they were 401 on the 13:59 UTC import). Onskyfall-apilogs:POST /api/import/finalize-assets/:idandPOST /api/import/finalize-font-metrics/:idmust arrive with 200. R2: objects under the project prefix for that target. - If either is still failing, rollback stays one line:
IMPORT_RUNTIME: "render"inworkers/wrangler.jsonc. - Then the two-week soak with Render warm, then Pass F (two entries up).
A session check-in is armed for 18:32 UTC to do step 2 automatically if an import has happened.
2026-09-02 — 12x12 “empty collections / empty repeaters”: what was wrong, what was not, what to do (D:ref-filter-identity)
Section titled “2026-09-02 — 12x12 “empty collections / empty repeaters”: what was wrong, what was not, what to do (D:ref-filter-identity)”Three parallel traces (importer row accounting, ingest→render, member accounts) plus read-only
queries on the live target 8061ca6a-27ad-419a-bbb9-053759d84a39 (imported 13:58 UTC).
Not a bug — data is intact. All 884 xlsx rows are accounted for: 137 public rows → 137 items in
the Site JSON → 137 content_items rows, published, correctly scoped (verified by id). The 38
zero-item collections are 33 Bondlayer form types with no submissions in the capture, two workbooks
that are empty in the export itself (Images – Past Editions, Anos (REF)), and the three member
collections. users (416 rows) and users_folders (331) are held out by the D:member-optin
consent gate; site_members for this target is empty — the “Import members” box was not ticked.
The bug (fixed in this PR): every repeater pre-filter on a reference field compared a pointer
object to an id string and matched nothing — FAQ, deadlines-by-edition, events-by-day. Engine-side
fix in packages/site-render, effective for the existing rows with no re-import.
Also true: 46 of the 82 repeaters bind to the two member collections (empty until members are
seeded; 24 of them are _user views that only render for a signed-in member), and the site has
never been published — builds is empty, every page/node is draft. Nothing on
12x12.matriz.page can be judged until the editor’s Publish button runs (it sends promote: 'all').
Owner actions, in order, to get 12x12 live:
- Merge the
D:ref-filter-identityfix (this branch) —skyfall-api+ editor redeploy automatically. - Re-import 12x12 into a FRESH target with “Import members” ticked (the container runs the
current importer, so this also picks up every importer fix since the last Render rebuild), OR keep
the current target and run
apps/bondlayer-worker/tools/users_to_members.pyagainst it. PATCH /api/targets/:id/member-settings→{ enabled: true, joinMode: 'invite' }; drop the members library’s sign-in form on a/sign-inpage. Seeded members can then sign in by magic link with their existing email — no account “recreation” needed (the seeded row IS the account).- Publish from the editor. Then check the repeaters on the canvas and on the published host.
- Follow-ups queued for their own PRs: (a) builder-triggered “invite all members” email
(
auth.invitetemplate +member_login_tokens— ~1 day,D:member-invite-send); (b) Google sign-in for members as a server-side OIDC redirect on the Worker (~1–2 days,D:member-google-oidc; 353 of the 416 emails are Gmail); (c) importer:binding_audit.pyfalse-warns on member repeaters,users_foldersis written twice under opt-in (public copy + member copy), andSign-upscarries member emails into the public CMS because its id does not match the member-gate heuristic.
2026-09-02 — Google sign-in for members: what to do in Google Cloud, and what to apply (D:member-google-oidc)
Section titled “2026-09-02 — Google sign-in for members: what to do in Google Cloud, and what to apply (D:member-google-oidc)”Follow-up (b) from the 12x12 entry above is built (branch claude/member-google-oidc). Members of any
target can now sign in with Google through the BUILDER’s own OAuth client; the magic link stays on.
353 of the 416 seeded 12x12 emails are Gmail, so once this is live and the site has the
“Continue with Google” block, most of those members are one click from their existing account
(join mode invite + the seeded row = no account “recreation”).
What the owner applies (once, prod):
- Migration
0038_site_auth_providers.sql+ sidecardb/migrations/20260902_site_auth_providers_rls.sql(db/MIGRATIONS.mdhas the rows; the sidecar is idempotent). - Confirm
MAIL_CREDENTIALS_KEYandMEMBER_SESSION_SIGNING_KEYare both set onskyfall-api(wrangler secret list --cwd workers). The flow 503s without either; the CMS card says “not available on this deployment” when the sealing key is missing.MAIL_CREDENTIALS_KEYnow seals OAuth client secrets too — rotating it invalidates every BYO email key AND every connected Google client; builders would have to re-paste their secrets. - Merge →
skyfall-apiand the editor redeploy automatically.
What a builder does per site (the CMS → Members → “Sign-in providers” card walks them through it):
- Google Cloud Console → APIs & Services → Credentials → Create credentials → OAuth client ID,
type Web application. If the project has no consent screen yet: External, site name, support
email; the
openid/email/profilescopes need no Google verification. Publish the consent screen (a “Testing” screen only admits listed test users). - Authorised redirect URIs — paste EVERY URI the card lists (copy buttons). One per host the
site answers on:
https://<subdomain>.<PUBLISH_ROOT_DOMAIN>/api/m/<targetId>/members/google/callback, the same path on each custom domain, and onhttps://api.matriz.design(serves the legacy/published/:idpath). Google matches these byte-for-byte; a missing one shows asredirect_uri_mismatchon Google’s side andmembers_error=google_failedon ours. When a custom domain is added later, come back and register its URI — the card’s list is live. - Paste Client ID + Client secret into the card → Connect. The secret is sealed on arrival; the card thereafter shows only its last 4 characters. “Update credentials” with the secret left blank keeps the sealed one; pasting a new one rotates it. Disconnect deletes the sealed secret.
- Drop the members library’s “Continue with Google” block on the sign-in page (it links to
/api/m/<targetId>/members/google/start, and hides itself for signed-in members viadata-member-visible="signed-out"). Member area must be enabled (the card says so if not). - Publish.
Visitor-facing error codes (all land on /?members_error=…, constant copy, nothing enumerable):
google_unavailable (provider not connected/disabled, member area off, or the secret can’t be
unsealed — operator side), google_denied (the visitor cancelled at Google), google_failed
(state/cookie/nonce/token verification failed, or Google rejected the exchange — the operator
detail is in the Worker log, never on the page), not_invited / blocked (join-mode, same as
the magic link).
Not in this PR: the mass “invite all members” email (D:member-invite-send, ~1 day) — the
Google button + seeded rows already let a member self-serve, but nobody is TOLD to. Other
providers (Apple, GitHub) would reuse site_auth_providers + establishMemberSession unchanged;
only the OIDC issuer constants differ.
2026-09-02 — Native visitor-auth blocks (D:member-auth-blocks): nothing to apply, one thing to rebuild
Section titled “2026-09-02 — Native visitor-auth blocks (D:member-auth-blocks): nothing to apply, one thing to rebuild”Branch claude/visitor-auth-components, stacked on #660. No migration, no secret, no CSP change. What
lands: a token grammar on data-member-visible (anon provider:google, pending, role:editor…),
GET /members/config (public booleans only), PATCH /members/me (self-profile), imageUrl on
/members/me, and 13 new Members-library blocks (sign-in-card, auth-notice,
pending-approval-banner, role-only, members-nav, invite-only-note, member-avatar,
member-greeting, member-role-badge, sign-out-everywhere-button, profile-form, avatar-upload,
account-page) plus tone/size variants on the sign-in surfaces. Spec: plans/MEMBER-AUTH-BLOCKS-SPEC.md.
Owner actions: merge after #660. Then, for 12x12: rebuild the /sign-in page with sign-in-card
(it carries the notice slot, so an expired link or a not-invited email finally shows a message instead
of a silent bounce to /), and drop account-page on /account. Everything else is per-site authoring.
Behaviour change to know about: an element whose data-member-visible carries a token the
runtime does not know is now HIDDEN (previously shown). Only hand-authored attributes can be affected —
every library block was audited — but an imported site that had a hand-typed value would lose that
element on publish, with the value visible in the DOM tree.
Deferred to you: Turnstile on published pages (D:turnstile-published: allow
challenges.cloudflare.com in script-src/frame-src and relax the invariant test for exactly that
host, or stay server-only) and delete-my-account policy. Phase 2 (importer mapping of Bondlayer’s
user-login/register/profile wrappers onto these blocks) is specced in §7 and not started.
2026-09-03 — What the last import actually ran through (D:importer-containers): container yes, materialize/trimscan 401 — pre-fix, still unverified
Section titled “2026-09-03 — What the last import actually ran through (D:importer-containers): container yes, materialize/trimscan 401 — pre-fix, still unverified”Checked against the live Worker settings and Workers Logs (not from config): skyfall-api has
IMPORT_RUNTIME=container, IMPORT_CONTAINER_ORIGIN=https://matriz-import-runtime.simaomartinez.workers.dev,
and the IMPORT_WORKER_TOKEN secret. No code reads the Render vars — all three dispatch sites
(routes/import-worker.ts, routes/import.ts, lib/trimscan.ts) go through
lib/import-runtime.ts, verified by grep. The three skyfall-keru.onrender.com vars still in
wrangler.jsonc are inert rollback values, retired at Pass F; they are not a mis-pointing.
What the container actually served (2026-08-30 → 2026-09-03):
| endpoint | count | status |
|---|---|---|
POST /import |
6 | 202 |
GET /import/jobs/:id |
28 | 200 (2 aborted) |
POST /materialize |
1 | 401 |
POST /trimscan |
1 | 401 |
GET /health |
4 | 200 |
Both 401s fall between 13:30 and 17:31 UTC on 2026-09-02 — after the cutover (#655) and before the auth-header fix (#656) deployed at 17:31. They are the known bug, not a new one.
What it cost the 12x12 import (target 8061ca6a-27ad-419a-bbb9-053759d84a39):
skyfall-api logged [import-materialize] Render dispatch rejected (401) … falling back to the in-Worker pass and then materialized all 196 assets in-Worker, so nothing was lost (one asset
failed on its own: stixtwotext-semibolditalic.ttf, source 404). Trimscan has no such fallback —
it no-ops to the editor’s browser-side lazy backfill — so that target’s server-side font metrics
were never extracted.
Why this looked like a mis-pointing. Both dispatch logs were written before the container
existed and said “Render dispatch rejected” in BOTH modes — so the container’s 401 read like the
importer was still aimed at Render. It was not: the 401 is in matriz-import-runtime’s own logs.
The messages now name the runtime and the exact host dialled, so the next reader does not have to
cross-check two log streams to answer “which host answered”.
Two open items, one action closes both. The container has served ZERO requests since the fix
deployed, so #656 is still unverified. Running POST /api/targets/8061ca6a-27ad-419a-bbb9-053759d84a39/fonts/trimscan
from the editor now would extract the missing metrics AND prove the header fix on the real path —
expect POST /trimscan → 200 on matriz-import-runtime instead of another 401. Do that before the
next re-import rather than after, so a failure is cheap.
2026-09-13 — Import-fidelity wave (PR #671, D256–D266) merged: five owner actions
Section titled “2026-09-13 — Import-fidelity wave (PR #671, D256–D266) merged: five owner actions”Six workstreams landed in one PR onto master (97da7dd54): split-canvas registry (D256), canvas
root parity + edit-mode wheel fallback (D257), canvas review comments/To-do list (D258), preview
top bar replacing the floating pill (D259), multi-locale publish (D260), custom-CSS migration
(D261), screen-root floor (D262), text-field types (D263), entity single-escape (D264), filter
defaults (D265), carousel current/rewind (D266). Follow-up specs for what didn’t land in the wave
itself: derived shared styles for the custom-CSS migration, Simão’s tab-link current state, the
canvas language-switcher locale threading, a TypographyParity.test.tsx timeout flake, and this
Figma sync.
- Apply the schema to prod Neon. Drizzle
0039_canvas_comments.sql, then the RLS sidecardb/migrations/20260913_canvas_comments_rls.sql(db/MIGRATIONS.mdhas both, marked NOT yet applied). This backscanvas_comments(D258) — do it before anyone opens preview mode in prod, since the new top bar fetches comments on entry and/api/comments500s without the table. Rebuild the Render importer image, or finish the— the same un-run action as index rows 1/2, now also gating this wave’s importer fixes: custom-CSS migration (D261), screen-root floor (D262), text-field types (D263), entity single-escape (D264), filter defaults (D265), carousel current/rewind (D266). ↳ 2026-09-14: this premise was stale on arrival — the cutover ran 2026-09-02, well before this wave (PR #671) landed 2026-09-13, soIMPORT_RUNTIMEContainer cutovermatriz-import-runtimealready ran every fix in it. Nothing to rebuild; item 3 (re-import) is the actual remaining step.- Re-import RPM, Simão and both 12x12 targets. Every importer-side fix above only shows up
after re-import; it also stamps
data-matriz-langso the new zero-JSlanguage_switcherelement (D260) has real per-locale links instead of the old<p link_locale>leak. - Republish all four targets. They are all multi-locale (
supported_languages: ["pt","en"], confirmed via API 2026-09-13) — republish is what actually emits the/en/*documents + hreflang (D260). Known scope cut carried forward: publish formats dates/numbers in the rendered locale, the canvas still formats in the default language; per-locale OG/RSS/archive-index/ print-box_languagegating is out of scope for this wave. - Figma-sync the new preview chrome.
figma-editor-syncskill (design/figma/FIGMA-SYNC.md) against filebZ17CtfEJ11rcV8UHNGKvh:PreviewTopBar(D259), AppSidebar “To-do” mode icon (ListTodo),CommentPinstates (open/resolved/draft),CommentThreadPopover, To-do panel row (D258).
Separately: the Cloudflare “Workers Builds: skyfall-api” check has failed on every PR branch in
this wave (#669, #670, #671) though it passed on #668, and wrangler deploy --dry-run bundles
clean on the integration branch — this looks like the CF preview-build environment, not the code.
Check the dashboard build log linked from the PR check.
2026-09-13 — 12x12: send sign-in invites to the seeded members
Section titled “2026-09-13 — 12x12: send sign-in invites to the seeded members”- Blocker: the 416 members seeded into the 12x12 target (
D:member-seed) have no password and never got a magic link — nobody has told them their account exists, soemail_verified_atis NULL for all of them and they cannot sign in on their own. - Done instead:
D:member-invite-sendshipped a builder-triggered invite — CMS → Members pane → “Invite all” (or per-row “Send invite”) emails each active/pending member a 7-day sign-in link through the site’s own sending identity, using the same redeem path a self-requested magic link uses. No new join logic; an invited email lands on the exact row the seed created. - Owner action (once the member area is enabled and the sign-in page is published for the 12x12 target):
- Confirm
targets.settings.members.enabled = trueandjoinModeis set as intended (inviteis the natural fit for a migrated, pre-seeded audience). - Open CMS → Members for the target, click Invite all, confirm the count in the dialog.
- Rate limits: 3 “invite all” requests per hour and 2 invites per address per day — skipped members are the per-email cap, not a failure; re-run after the window if a batch needs a second pass.
- A single member can be re-sent from their row menu (“Send invite”) without touching the rest.
- Confirm
2026-09-14 — Importer cutover reconfirmed permanent: container is the sole live host, Render decommission (Pass F) is the one open action left (D:importer-containers)
Section titled “2026-09-14 — Importer cutover reconfirmed permanent: container is the sole live host, Render decommission (Pass F) is the one open action left (D:importer-containers)”Docs sweep session, cross-checked against the file’s own § 2026-09-02/2026-09-03 entries above (not re-verified against live Cloudflare/Render dashboards in this session — taken from the owner’s report and this ledger’s existing record). Purpose of this entry: the index above (dated 2026-09-02) and the § 2026-09-13 entry’s item 2 both still read as if the cutover were an open choice (“rebuild the Render image, OR finish the cutover”) when the two entries directly below them already documented it as DONE the same day it happened. That contradiction is now annotated in place (↳ notes on index rows 1/2/13 and on § 2026-09-13 item 2) rather than rewritten — this entry is the pointer those annotations resolve to.
Current state: skyfall-api runs IMPORT_RUNTIME="container" with IMPORT_CONTAINER_ORIGIN
set to the matriz-import-runtime Worker origin. Render (skyfall-keru.onrender.com) no longer
serves any importer traffic — every dispatch site (routes/import-worker.ts, routes/import.ts,
lib/trimscan.ts) resolves through lib/import-runtime.ts, which reads IMPORT_RUNTIME and picks
the container branch. The three onrender.com vars still in workers/wrangler.jsonc
(IMPORT_WORKER_ORIGIN, BONDLAYER_MATERIALIZE_URL, BONDLAYER_TRIMSCAN_URL) are inert — kept
only so flipping IMPORT_RUNTIME back to "render" still works as a rollback.
Still open — Pass F, decommission (from the spec, § 2026-09-02 above):
- Confirm the two-week soak (from the 2026-09-02 flip) has been clean — wizard imports succeeding,
/materialize//trimscanfinalize callbacks landing, no unexplained container errors. - Retire the three
onrender.comvars fromworkers/wrangler.jsonc(or leave them as a permanent documented rollback value — the spec recommends keeping theIMPORT_RUNTIMEseam pointed only at the container; that’s an owner call, not made here). - If the Render service still exists: suspend it, then delete after one more cycle.
- Rotate
IMPORT_WORKER_TOKEN(bothskyfall-apiandmatriz-import-runtime) and the R2 credentials Render held, now that Render no longer needs them. - Update the tracker’s
agent_work_stateto reflect the importer as container-only.
Not done by this entry: no vars, secrets, or runtime behavior were changed — this is a docs-only pass reconciling the ledger with what already happened. Pass F itself is still the owner’s to run.
2026-09-14 — 12x12: apply the recurring-structures shape
Section titled “2026-09-14 — 12x12: apply the recurring-structures shape”- Blocker:
D:recurring-structures(plans/RECURRING-STRUCTURES-AND-LIVE-TIME-PLAN.md) built and merged Waves 0/1 — symbols, form gates/derive, scoped upsert, member edit gate, rollups, thetableelement,include=submitter,collapse_slots_to_array— but nothing has actually pointed any of it at the real 12x12 target yet (Wave 2, “12x12 application”, was not started). 12x12 is still running on the old per-year “0.4 Fases 2024” collection and the wideboolean_fase_*/ref_folder_*fields onusers, none of which gate anything live. - Done instead: this session proved the WHOLE shape end-to-end against real Postgres (PGlite) —
submit → derive (
edition/period/price/submitted_at) → scoped upsert (re-submission replaces, never duplicates) → member edit gate (open inregular, closed inlate, reopened once the admin flips the period’sgates.edit) → admin publish/unpublish (member’s own list still sees adraftrow) → rollup count on aparticipants-style item → the publish-sidetableelement’s identity-column gating (dropped without a role gate, kept-and-marked with one) →?include=submitter(email for a role-gated member, 404 for the wrong role or anonymous) →collapse_slots_to_arrayidempotence (a re-run of an already-collapsed row plans zero writes) → a submission landing in a schedule GAP correctly 403s closed. Seeworkers/test/node/recurring-12x12.pglite.test.ts. It also wrote a one-shot CLI,apps/bondlayer-worker/tools/12x12_recurring_setup.py, that reads ONLYdesign.schemas(field NAMES, never a row) from the real capture and emits three payloads the owner pastes in below —symbols.json,passes.json,schedule-2024.json— with every date/price left EMPTY (the tool never read a row value; see the script’s own docstring andtest_12x12_recurring_setup.py). - Owner action (ordered; rows 1/14 in the index above — the re-import that lands
editions/users_foldersas real Matriz collections — must have already run). Steps 2, 3 and 6 below are now walkable end-to-end from CMS → Recurring setup (/cms/:targetId/recurring,RecurringSetupPane.tsx) — a status-driven checklist (Done / Needs attention / Not started per step) that wraps the same APIs; each still lists its scriptable equivalent for anyone who’d rather paste the tool’s JSON directly. Steps 1, 4, 5, 7–9 still go through the raw CMS/editor surfaces named below (step 1’s collection picker and step 4’s form settings panel are also reachable, unguided, from steps 1 and 6 of the same pane):- Run the setup tool against the current capture, into a scratch directory (never inside the
repo — it refuses otherwise):
It prints which of “1.0 Edições” / “0.4 Fases 2024” /
Terminal window python3 apps/bondlayer-worker/tools/12x12_recurring_setup.py \--editor-json imports/12x12/nrkdmrhhrk.editor.json \--out-dir /path/to/scratch/12x12-recurringusers_foldersit found and writessymbols.json,passes.json,schedule-2024.jsonunder that directory. - Save the
editionsymbol from the Recurring setup pane. Open CMS → Recurring setup for the target. Step 1 (Container collection) pickseditionsand, once it has aschedulefield, that field; step 2 (Live pointer) names the symboledition, mode is fixed to “window”, start/end fields default to the pane’s own date-field heuristic — check them against the realdatetime_data_inicio/datetime_data_de_fimfield ids fromsymbols.jsonrather than trusting the guess blind — and “If no window is open” is set tolatest; one Save (on step 2) writes both pieces. Scriptable equivalent:PATCH /api/targets/:targetId/symbolswith the body insymbols.json. - Run the
users_folders→workscollapse pass from the Data tab. Dashboard → project view → Data tab → theusers_foldersrow → “Collapse slots into one field…” opens the Collapse slots wizard (the same dialog the Recurring setup pane’s step 5 opens once it detects the numbered field family): slot patternimage_img_{n}(auto-suggested and already matches —passes.json’sslot_patternvalue uses this exact{n}grammar, confirmed against theSLOT_PATTERNconstant inapps/bondlayer-worker/tools/12x12_recurring_setup.py, so no glob-vs-{n}fixup is needed), target fieldimages, “Retire the slot fields after collapsing” ON. Preview renders the dry-run census (rows, slots filled/empty, rows with no images, rows to update) as the consent screen — nothing is written until you hit “Confirm & run”; if the source changed underneath you, the wizard shows a fresh census and makes you re-confirm rather than silently retrying. This collapses the twelveimage_img_Nslots into oneimagesfield per row, one row per (artist, edition) — no dedup pass needed if the source rows are already one-per-edition. Scriptable equivalent:passes.jsonstill needs itssource_collectionplaceholder (REPLACE_WITH_users_folders_CONTENT_TYPE_ID) replaced with the real content-type id onceusers_foldersexists as a Matriz collection, thenPOST /api/targets/:targetId/passes/previewwith that body (dry-run, writes nothing), thenPOST /api/targets/:targetId/passes/runwith the SAME body plus thecensus_hashthe preview returned (the route 400s without it — re-consent by design). - Add the
schedulefield to theeditionscollection in the CMS (typeschedule, one field), then open the schedule editor for the 2024 edition item and fill in the THREE placeholder periods fromschedule-2024.json(early/regular/late) —start/end(ISO 8601) anddata.priceare EMPTY in the file on purpose; read them off “0.4 Fases 2024”’s actual rows in the CMS UI, by hand, one time. Add or remove periods freely — three is a starting shape (OD-E), not a limit. - Build the ONE
worksform in the editor’s Form settings panel, bound to the collapsedworkscollection: identity scopeupsertScope: ['edition'](re-submission replaces the same artist+edition row rather than duplicating), gate left implicit (no explicit open/close dates — it comes from thesymbol_periodderive rule below), derive rules[{kind:'symbol', symbol:'edition', set:'edition'}, {kind:'symbol_period', symbol:'edition', set:'period', copy:{price:'data.price'}}, {kind:'stamp_time', set:'submitted_at'}]. - Turn on the edit gate from the
workscollection’s Schema designer. CMS → openworks→ Schema designer → the Member editing card at the bottom (MemberEditGateEditor, also embedded inline as step 4 of the Recurring setup pane once its step 3 has pickedworksas the participation collection — use either surface, not both) → toggle “Close member edits automatically by period” → Period source@current.edition→ Match fieldedition(the reference field onworkspointing ateditions) → Save. This is what makes a participant’s own edit close automatically when the current period’sgates.editis false, and reopen the moment an admin flips it — no code change either time. Scriptable equivalent:PATCH /api/content-types/:idwithschema.member_edit_gate: {symbol:'edition', match_field:'edition'}(400s on a bad shape;nullclears it —workers/src/lib/member-edit-gate.ts). - Build the member-facing + admin pages: members area (
account-page+gallery-upload+my-works-gallery, rollup-fed) for participants to submit/edit their own row and see its status; a separatemembers_only+required_role:'admin'page carrying atableelement bound toworksfiltered to@current.edition, columns includingsubmitter.email/submitter.name/period/price/status(the identity columns render ONLY because the page is role-gated — the same page withoutrequired_roledrops them, fails closed) — export XLSX/CSV from that table is the payment sheet. - Invite members (row 18 above, if not already done) and republish the target — the symbol, form, gate and table all need a fresh publish to take effect on the live pages.
- Follow-up, not this pass: retire the wide
users.boolean_fase_*/ref_folder_*/boolean_inscrito_*fields once the rollup + the newworkscollection are verified live for at least one submission cycle (§11 of the plan defers this deliberately).
- Run the setup tool against the current capture, into a scratch directory (never inside the
repo — it refuses otherwise):
2026-09-14 — Three CV “Tab Link” nodes lost their current style layer on disk
Section titled “2026-09-14 — Three CV “Tab Link” nodes lost their current style layer on disk”- Blocker: none for the code — the defect behind it is fixed (PR #694, plus the three residual
gaps in its follow-up; ledger entry
D:computed-style-layer-loss). What is left is a DATA repair this run cannot make: threedom_nodeson the 12x12 CV page (target05f6e9ef-369a-4797-a0c1-0c6ebe88ef86, page995903e1-2635-5cbc-bd9d-b10a7e8e6547, all three linked to shared style1fb7c3b2-7972-50f6-980c-da99f6decb7e“CV-Tab-Link”) holdstyle_overrides = {}where D266’s_ensure_tabs_link_current_lookwrote{"current": {"background-color": "#ddd"}}. They were flattened 2026-09-14 ~13:51 UTC, ~33 min after the import’screated_at, by the editor’s detach/unlink/push merge helpers rebuildingstyle_overridesfrom a layer allowlist that did not namecurrent. - What the run did instead: fixed the code and pinned it with regression tests. No live DB
write — this run has no prod Neon credentials for the repo’s own project, and a hand-written
UPDATEagainstdom_nodesis owner territory. - Owner action (either one, not both):
- Re-import the 12x12 target (row 14 above). The importer re-derives the layer and the upsert is by deterministic uuid5 id, so the three rows come back correct with no bespoke SQL. Preferred — it is an action already queued for other reasons.
- Or repair in place, if a re-import is still far off. For each of the three “Tab Link” nodes
on that page,
PATCH /api/nodes/:id(Clerk-authed,?target_id=…) with{"style_overrides": {"current": {"background-color": "#ddd"}}}, then republish the target. Check first whether the shared style1fb7c3b2-…already carries acurrentlayer — if a “push to style” is what flattened them, the push may have landed a partial copy there, and the node-level layer should not double up with it.
- Check it stays fixed: after either repair, open one of the tab links in the editor, use
“Unlink from style”, and confirm the
currentlayer survives in the Style Layers panel. Pre-fix, that sequence was itself a second way to destroy it.
2026-09-14 — simaomartinez CV tabs: a shared style’s current edit invisible on 2 of 3 tabs — importer synthesis moved to the shared row (D:tabs-current-shared-write)
Section titled “2026-09-14 — simaomartinez CV tabs: a shared style’s current edit invisible on 2 of 3 tabs — importer synthesis moved to the shared row (D:tabs-current-shared-write)”- What was wrong, verified live: target
7735424f-a4e5-4f4d-84db-0b587334d9e7,/cvpage, shared styleCV-Tab-Link(d32569ca-d14f-5931-9371-40ece7fe3bc9). Itscurrentlayer correctly reads{"background-color": "#DEDEDE0A"}(a real authored translucent-white,last_authorirrelevant — that column defaults to'human'at insert and the style PATCH route never writes it either way) — but only the FIRST tab link (whose ownstyle_overridesis now{}, matchingPropertyCommands.pushOverridesToStyleclearing the ONE node it pushed from) shows it. The other two links still carry a PRIVATEcurrent: {"background-color": "#ddd"}written at import time (pre-fix,_ensure_tabs_link_current_lookinapps/bondlayer-worker/src/element_mapper.pystamped this identically onto every member of the shared-style group), and node overrides beat the shared style at render (resolveNodeStyles), so those two mask the author’s edit forever — push-to-style only ever clears the ONE node it is called on, never its siblings. - Root cause is the importer, not the push path — PR #694/#697 already made
computeDetachedStyleswhole; verifiedpnpm-free by direct API reads against the live target (last_author/timestamp instrumentation and thestyles.tsPATCH route both checked). The importer wrote a private per-node copy of the framework default to EVERY member of a shared-style group instead of once to the row — exactly the shape that lets one member’s later edit go invisible on its siblings. - What this run did: fixed
_ensure_tabs_link_current_lookto synthesize onto the SHARED STYLE row (once) instead of per node, on branchclaude/fix-shared-style-current. Verified by dry-run import + offline render (packages/site-render/tools/render-site-json.ts) showing all three links now resolve.state-currentfrom the one shared row, and by simulating a shared-style edit post-import — every link picks it up with no per-node copy left to go stale. No live DB write (no prod Neon credentials here); did not push or open a PR. - Owner action needed for the LIVE target — pick one:
- Re-import
7735424f-…(or whichever target is current by the time this is read). The fixed importer no longer writes a private copy, so a fresh import is naturally correct going forward. This does NOT retroactively fix the CURRENT rows — a re-import creates fresh dom_nodes/styles rows keyed off the capture, it does not repair the two already-broken links in place. - Or repair in place:
PATCH /api/nodes/:id?target_id=7735424f-…on the two non-selectedTab Linknodes with{"style_overrides": {}}(clearing their stale privatecurrent, same as the first link already has), then republish. Do NOT write anything to the shared style — it already carries the correct#DEDEDE0A.
- Re-import
- Separate, smaller bug noticed while tracing this (not fixed here, flagged for its own pass):
PATCH /api/styles/:id(workers/src/routes/styles.ts) never includesupdatedAtin its update set, andcomponent_styles.updatedAt(packages/schema/src/schema.ts) has no$onUpdatedefault — so an edited shared style’supdated_atnever moves, which reads exactly like “never touched since import” during any future forensic timestamp check like this one.
2026-09-19 — D293 fixed; prod audit clean, no repair needed
Section titled “2026-09-19 — D293 fixed; prod audit clean, no repair needed”- Bug (go-live blocker for 2026-09-21): the editor’s own page-creation path
(
frontend/src/lib/pages.ts’screateRootNode, andduplicatePage’s clone loop) never honoured the page/root shared-id contract (site_pages.idIS its page-rootdom_nodes.id) — it POSTed apage_idkey that isn’t adom_nodescolumn, Drizzle silently dropped it, and the new root got a random uuidGET /api/nodes?page_id=<pageId>could never find. Every page created viaPagesPanel.tsx/CreateCollectionPageDialog.tsx/PageFormatDialog.tsx(not imported ones — the Bondlayer importer already handled this correctly) would render blank after a reload. Full root-cause + fix inDECISIONS.md(D293). - Fix landed on
claude/peaceful-lovelace-5rn7d6:createRootNodenow sendsid/is_page_root/page_pathexplicitly;duplicatePagedoes the same for the cloned root;POST /api/nodesnow 400s on a straypageIdbody key instead of silently dropping it. PGlite coverage added (workers/test/node/nodes-create-page-root.test.ts). - Prod repair audit (item 4 of the fix spec) — ran clean, nothing to repair: queried Neon
project
Skyfall-V2(round-dust-84750148) directly —site_pagesrows with nodom_nodesrow of the same id: 0 rows. Existing page-rootdom_nodesrows withis_page_root/page_pathnot matching theirsite_pagesrow, or with a non-nullparent_id: 0 rows. No prod pages are currently affected — every live page-root already has the correct shared id,is_page_root: true, and matchingpage_path. No repair migration needed; no owner action required for this item.
2026-09-20 — Collection controls & sliders: workstream spec written, owner decisions + re-import needed
Section titled “2026-09-20 — Collection controls & sliders: workstream spec written, owner decisions + re-import needed”- Blocker: the 2026-09-19 report “imported filters don’t work / not previewed on canvas / not clickable” has three stacked causes (see
plans/COLLECTION-CONTROLS-WORKSTREAM.md§0–§1): (1) the canvas repeater expansion never stamps per-itemdata-mf-<slug>markers, so Preview-mode filtering hides every row — reproduced, admitted by the runtime docblock and D178, contradicted by the 2026-09-15 COLLECTION-CONTROLS.md audit; (2) a fresh import leaves 101 of 102 Bondlayer filter buttons non-functional (D111/D112/D129/D163/D167 posture + undocumented gaps: nodata-mf-clear, no dangling-target check, nodata-mf-page-size); (3) every live imported target predates the Aug–Sep fixes — 12x12 is a stale import, RPM and Simão return 404 on every URL and on the target-id route (Worker is up; consistent with row 7’s purge door having been run, undistinguishable from an outage without DB access). - Done instead: the workstream spec (16 S-items, S18–S33, dependency graph, verification matrix, agent rules, 13 decision slugs) is checked in; nothing else changed.
- Owner action: (a) answer the spec’s §8 Q1–Q6 (tab placement, rebind heuristic, fieldless-button posture, fade sliders, legacy pager path, Phase D priority); (b) confirm purge vs outage for RPM/Simão; (c) after Phases A–C land, run the § 2026-08-14 re-import runbook on all four targets and republish (S33) — rows 14/15 stay open until then.
↳ 2026-09-20 (
D:controls-workstream): owner answered Q1–Q4 and Q6 (spec §8: settings move to the Data tab; auto-rebind narrowed; fieldless buttons kept decorative; Embla Fade; record navigation + server query consumer scheduled as S34/S35). Q5 confirmed C′ (compile-time legacy-pager alias) on 2026-09-21. The fieldless-button question was traced: 40 of 51 are configured no-value filters killed by one importer guard, 9 are Clear buttons, 2 are D167 refusals. Still open: purge-vs-outage for RPM/Simão, and the re-import (S33).
2026-09-21 — RPM/Simão ignored; 12x12 deleted ahead of re-import; publish-timeout problem reported
Section titled “2026-09-21 — RPM/Simão ignored; 12x12 deleted ahead of re-import; publish-timeout problem reported”- Note, not a blocker: owner says ignore the RPM/Simão 404s (row 22 / H1 in the collection controls workstream) — not investigated further.
- Note, not a blocker: owner deleted the 12x12 target 2026-09-21 ahead of a planned re-import
(S33 of
plans/COLLECTION-CONTROLS-WORKSTREAM.md). While it was live, publish timed out, especially on the first publish of the session — 12x12 is a large capture. Owner floated an optimized publish/deploy cache (rebuild only what changed) as a possible fix. Investigated and speced as S37 in that same plan; see its §12 for findings.
2026-09-21 — Collection controls workstream batches 1+2 merged; publish-timeout fix shipped (PR #720)
Section titled “2026-09-21 — Collection controls workstream batches 1+2 merged; publish-timeout fix shipped (PR #720)”- Done, not a blocker: all 19 collection-controls-workstream implementation items (S18–S32,
S34–S37) are merged into
claude/confident-cray-5evewb, PR #720, ready for review — seeplans/COLLECTION-CONTROLS-WORKSTREAM.md§13 for the full integration log (merge order, every real cross-branch conflict and how it was resolved, coincidental-boundary merge hazards caught, cumulative verification results). Row 22 above is closed except for the re-import. - Done, not a blocker: the publish-timeout problem reported directly above (this same date) was root-caused and fixed as S37 — sequential R2 writes were hitting the client’s own abort deadline, not a Cloudflare CPU/platform limit (verified against current Cloudflare docs; no such limit exists for HTTP-triggered Workers). Fix: bounded-concurrency parallel R2 writes, decoupling ordered CPU-bound page rendering from fanned-out I/O-bound writes. Measured 24.8x speedup at 300 puts in the merged benchmark test. The owner’s own incremental-cache idea (S38, rebuild only what changed) is a real, complementary follow-up but would NOT by itself have fixed the reported symptom — a first publish has nothing cached to skip — so it stays speced-only, deferred, not scheduled.
- Owner action, unchanged: merge PR #720 to master, then run the § 2026-08-14 re-import runbook on RPM/Simão/12x12 (S33) — the only remaining item in the workstream.
2026-09-23 — GitHub Actions is out of minutes: move CI to a free runner (one repo variable)
Section titled “2026-09-23 — GitHub Actions is out of minutes: move CI to a free runner (one repo variable)”2026-09-27: which variable to set is superseded by § 2026-09-27. Jobs now pick a runner by tier.
CI_RUNS_ONis still every tier’s fallback and the only setting for the deploys, so setting it toself-hostedwould move the deploys onto that runner. Leave it unset.
What happened. Since 2026-09-23 every Actions job fails 2–4 s after being queued, with no runner, no steps and no log. The check-run annotation reads: “The job was not started because recent account payments have failed or your spending limit needs to be increased.” The owner confirmed that the account’s included minutes are used up.
Why the minutes ran out. From 700 runs between 2026-09-14 and 2026-09-23, with job durations rounded up per job the way GitHub bills them: ≈5,200 billed minutes in 9 days, ≈17,000 a month. A private repo on a personal account includes 2,000 a month (3,000 on Pro).
| Driver | Share | Note |
|---|---|---|
| Frontend CI (PR ≈18 min/run, master push ≈15) | 52% | 8 jobs per run: 4 test shards + lint/typecheck/build + aggregate, each doing its own install and rounded up to a minute. |
| Workers CI (PR ≈10, push ≈12) | 34% | Same shape: 4 shards + lint/typecheck + live on push + aggregate. |
| Importer, Packages, DB guard | 10% | |
deploy-api.yml + deploy-editor.yml |
2.4% | Duplicates of Workers Builds / Pages Git integration. Now manual-only (row 4). |
Path filters, concurrency cancel-in-progress groups and draft-PR skips were already in place.
The volume is real work (≈235 PR pushes in 9 days), so trimming cannot bring 17k down to 2k. The
fix is a runner that GitHub does not bill.
Already wired, no workflow edit needed. Every job in all 10 workflows runs on
${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}. The variable is unset today, so jobs ask for
GitHub-hosted runners. Set it and every workflow moves; delete it and they move back.
Options, ranked (prices checked 2026-09-23):
| # | Option | Ongoing cost | Owner effort | Secrets exposure | Workflow change |
|---|---|---|---|---|---|
| 1 | Self-hosted runner on your own machine (WSL2 Ubuntu on Windows, or any Linux/Mac box) | $0. GitHub’s planned $0.002/min self-hosted fee is postponed, not in effect | ~15 min | Stay on your machine; the repo is private, so only your own and your agents’ branches run there, the same code the pre-push hook already runs | Set CI_RUNS_ON=self-hosted |
| 2 | Self-hosted runner on Oracle Cloud Always Free (Ampere A1, arm64, now 2 OCPU / 12 GB, cut from 4/24 in 2026) | $0; a card is needed at signup | ~1–2 h; A1 capacity is often “out of capacity” in busy regions; Oracle reclaims instances idle for 7 days (<20% p95 CPU) | Stay on your VM | Same variable |
| 3 | Ubicloud managed runners (ubicloud-standard-2) |
1,250 free min/mo ($2.50 credit), then $0.00125/min, ≈$20/mo at today’s volume; card required | ~10 min (GitHub App) | Ephemeral VMs at a third party | CI_RUNS_ON=ubicloud-standard-2 |
Ruled out: Blacksmith (3,000 free/mo, then $0.004/min, ≈$55/mo; org-only, so the repo would have to move to an organization and the Cloudflare Git integrations be re-linked). GCP e2-micro (1 GB RAM; the vite build is given a 4 GB heap). CircleCI free tier (≈3,000 Linux min/mo, and all 10 workflows would need rewriting). GitLab free tier (400 min/mo). Raising the GitHub spending limit (≈15,000 overage min × $0.006 ≈ $90/mo). Making the repo public (standard runners are free and unlimited for public repos) is also possible; that decision is yours and is only noted here.
Owner steps — option 1 (recommended to start; option 2 is the same steps on the VM):
- On the machine (Ubuntu or WSL2 Ubuntu):
sudo apt-get install -y git curl jq build-essential docker.ioand thensudo usermod -aG docker $USER(log out and back in). The workflows install Node, pnpm and Python themselves, but they expectjq,curl,gitand Docker to be on the host. - GitHub → no-tag/SKYFALL → Settings → Actions → Runners → New self-hosted runner → Linux,
x64 (or ARM64 on Oracle). Run the download +
./config.sh --url … --token …commands it shows, accepting the default labels. Then runsudo ./svc.sh install && sudo ./svc.sh startso it survives reboots. Repeat into a second directory for a second runner if the machine has ≥ 8 GB free: a PR fans out to ~8 jobs, and one runner takes them one at a time. - Settings → Secrets and variables → Actions → Variables → New repository variable:
CI_RUNS_ON=self-hosted. - Re-run the failed checks on an open PR (or push to one). Jobs should show your runner’s name.
The existing
NEON_DATABASE_URL/CLOUDFLARE_*repo secrets keep working unchanged.
Things to know: jobs queue while the machine is off and fail after 24 h waiting. Delete the
variable to fall back to GitHub-hosted runners once minutes reset. The live Workers job writes
.env.local with the Neon URL into the runner’s work directory; actions/checkout cleans it on
the next job. The importer image smoke test now uses a per-run image tag and a random host port,
so two runners sharing one Docker daemon don’t collide. Not changed: the 4-way test sharding.
It saves wall time but roughly doubles billed minutes on GitHub-hosted runners (per-job
rounding plus an install per shard). On a free runner that no longer matters.
2026-09-27 — CI runner tiers, measured (D:ci-runner-tiers)
Section titled “2026-09-27 — CI runner tiers, measured (D:ci-runner-tiers)”§ 2026-09-23’s one-variable switch (CI_RUNS_ON) is replaced by one variable per kind of job,
because the first attempt on the maintainer’s Hetzner VPS failed: that runner has a 1.8 GB
memory cap shared with other repos’ runners, no Docker (and must never get it), and one run
took the disk from 13 GB to 3.7 GB free. Superseded draft: no-tag/SKYFALL#817.
Measured 2026-09-27 (1-vCPU sandbox, Node 24; peak = RSS of the whole process tree, what a cgroup cap sees; “3 workers” is vitest’s default there):
| Workflow · job | Time | Peak RSS | Docker / secrets | Tier |
|---|---|---|---|---|
| frontend-ci · lint (site-render + frontend eslint + depcruise) | 49 s | 1.0 GB | — | checks |
frontend-ci · typecheck (site-render + tsc -b) |
48 s | 1.35 GB | — | checks |
| frontend-ci · build, before (preview config: babel pass on) | 45 s | 1.9 GB; OOMs at a 1 GB heap | read 2 dead VITE_NEON_* secrets |
— |
frontend-ci · build, after (production config, CF_PAGES_BRANCH=master) |
22 s (28 s at a 1 GB heap) | 2.0 GB default heap / 1.55 GB at 1 GB | none | heavy |
| frontend-ci · test, full suite (507 files) | 340 s (3 workers) / 873 s (1) | 2.2 GB / 1.43 GB | — | heavy |
| workers-ci · lint + depcruise | 25 s | 0.9 GB | — | checks |
| workers-ci · typecheck | 15 s | 1.1 GB | — | checks |
| workers-ci · test-node, full suite (296 files, PGlite) | 274 s (3 workers) / 607 s (1) | 3.7 GB / 1.64 GB | — | heavy |
| workers-ci · live (workerd pool + live Neon) | not run here | — | NEON_DATABASE_URL |
CI_RUNS_ON |
| packages-ci · typecheck + lint + test | 35 s | 0.9 GB | — | checks |
| db-guard · db:check + db:drift | 4 s | 0.6 GB | — | checks |
| importer-ci · tests (Python, src + tools) | 38 s | 0.2 GB | — | checks |
| importer-ci · edge (typecheck + lint + test) | 4 s | 0.5 GB | — | checks |
| importer-ci · image (build + /health) | not run here (no Docker) | — | Docker | docker |
| deploy-api/-editor/-docs-dev/-docs-product/-import-runtime | not run | — | Cloudflare secrets (+ Docker for import-runtime) | CI_RUNS_ON |
The scoped test selection rarely saves anything on this code base: a real 31-file merge (#816) still selected every frontend and workers test file. Budget the full-suite times.
Disk. A cold pnpm install fills a 991 MB pnpm store. Add 158 MB of checkout
and the links, and it comes to 1.2 GB. The Actions pnpm cache is a ~258 MB compressed tarball of that store. On a
self-hosted runner it was pure cost: downloaded and unpacked over the store already in the
runner user’s home, then re-tarred at job end. .github/actions/pnpm-install now skips it
there (runner.environment == 'self-hosted'). The store is shared by every job and runner
instance of that OS user. So steady state is ~1 GB once, plus ~0.2 GB per runner workspace,
plus the Node 20 and 22 toolchains (~0.2 GB each, once). The biggest items in the store are
two workerd binaries (121 + 143 MB: wrangler 4.105 vs 4.124), two lucide-react versions and
monaco-editor (74 MB). Deduping those is a separate lockfile change. The store never shrinks
by itself, so give the runner user a weekly pnpm store prune (cron).
What to set (Settings → Secrets and variables → Actions → Variables):
| Variable | Set to | Jobs |
|---|---|---|
CI_CHECKS_RUNS_ON |
vps (the small runner’s label) |
lint, typecheck, frontend/Workers unit + integration tests (Linux) aggregates, packages-ci, db-guard, importer tests + edge |
CI_HEAVY_RUNS_ON |
oracle once that runner exists |
frontend + workers test shards, frontend-build |
CI_TEST_SHARDS |
[1] if the heavy tier is ONE runner; leave unset (4 shards) on GitHub-hosted |
shard list for both test jobs |
CI_DOCKER_RUNS_ON |
a runner label with a Docker daemon, or unset | importer image |
CI_RUNS_ON |
leave unset | deploys, workers live: these read Cloudflare/Neon secrets |
Set 2026-09-28: CI_CHECKS_RUNS_ON=vps, CI_HEAVY_RUNS_ON=oracle, CI_DOCKER_RUNS_ON=oracle
(CI_RUNS_ON still unset). Verified on the first master run after setting these: every checks-tier
job landed on vps-nbg1-skyfall, every heavy/docker job landed on oracle-ci-arm-skyfall, and
workers-live failed exactly on the known billing block — no ARM-specific code failure. CI_TEST_SHARDS
was left unset, so the 4 frontend/workers test shards queue one at a time on the single Oracle
runner instead of in parallel, and one shard (workers-test-node 2/4) failed twice with an
infrastructure error inside the runner’s antes.sh hook (Pre-create every slots: No such file or directory, exit 127) rather than a test failure — consistent with that hook being mid-edit for
the planned 2-concurrent-slot change; re-run once that lands. Runner registration, labels,
disk/memory limits and the archive-first queue gates live in no-tag/vps-infra:
bootstrap/runner-hooks/LEIA-ME.md, MAQUINAS.md,
ORACLE-RUNNER.md — don’t re-derive them here.
Unset tiers fall back to CI_RUNS_ON, then ubuntu-latest. Until billing is fixed those jobs
queue and the aggregates stay red, which is honest about what did not run. pnpm ci:local --pr runs the same checks on the maintainer’s Mac in the meantime.
Which runner, from the numbers. Everything on the checks tier fits in ≤ 1.35 GB and ≤ 50 s,
so it fits the VPS, one job at a time. The test shards (1.4–1.6 GB for 10–15 min on 1 vCPU) and
the build (~1.55 GB) need the big box. On the VPS they would hold nearly the whole shared cap,
and starve the other repos’ runners for the length of a suite. The Oracle box (24 GB) takes
them with room to spare: at 4 vCPU vitest runs 3 workers (2.2 GB / 3.7 GB peak, ~5 min).
Register the SKYFALL runner there with a custom label (oracle). If it has Docker, point
CI_DOCKER_RUNS_ON at it too. Keep the VPS runner’s labels off every other tier.
2026-10-03 — Marketing test queue (email designer: library, dark mode, rich text, send gates)
Section titled “2026-10-03 — Marketing test queue (email designer: library, dark mode, rich text, send gates)”Blocker: none in code — these are checks only real inboxes can answer. PRs #921 (merged) and
#928 shipped the designer upgrade, brand styles, saved/synced sections, image uploads, dark mode,
rich text and the legal-footer gates (D:email-designer-classes-tags, D:email-library,
D:email-dark-mode, D:email-rich-text). Migration 0052_email_library + its RLS sidecar were
applied to prod 2026-10-03 (MCP, ledger row 58).
Owner action — on a throwaway project, tick each line; note anything off in the PR or a new issue.
Background on what each app does: docs/Research/EMAIL-CLIENT-RENDERING.md.
Before tomorrow’s first campaign (on the REAL campaign template):
- Pre-send checks show Ready (or only warnings you accept) — header button next to Test send.
- Test send to yourself → the footer shows your mailing address; Unsubscribe works and the address then stops receiving (suppression).
- Subject + preheader read right in the inbox list (the designer’s inbox row is a preview of this).
- Open it on a phone: columns stack, images fit, nothing scrolls sideways.
Dark mode (set Email tab → Dark mode colours + a dark-mode logo on one image): 5. Apple Mail (iPhone and/or Mac, system in dark mode): your dark colours apply, the dark logo swaps in. 6. Gmail app on iPhone: expect a FULL colour inversion, your settings ignored — compare with the preview’s “Gmail iOS, Outlook Windows” option. 7. Gmail app on Android: expect only light backgrounds darkened / dark text lightened — compare with “Gmail Android, Outlook.com”. 8. Outlook (app or outlook.com) if you have it: dark colours should apply (it honours them). 9. Record per app: “matches preview / differs how”. Differences tune the simulator.
Rich text, sections, styles, images: 10. A text block with bold, italic and a link (B / I / link buttons): renders in Gmail and, if available, Outlook desktop; the link is underlined and in your accent colour. 11. Save a header as a section, insert it into two templates with Keep synced, edit the section once → both templates’ previews and their next test sends show the edit. Insert it once WITHOUT sync → that copy does not change. 12. Edit a brand style (Styles tab) → every email using it changes. 13. Upload a large photo (phone JPEG/HEIC) to an image block → test send shows it in Gmail (public address works), and it isn’t huge (shrunk to ≤1200px). 14. A row background on a block shows edge to edge in Gmail and Outlook.
The send gates: 15. A marketing template with no footer (or a footer without address): the campaign Send is refused with the reason and the campaign stays a draft. 16. A flow sending that template can’t be switched on until it’s fixed.
Agents: 17. Over the Dev MCP, list_email_styles / list_email_sections return the project’s
library; create_email_template accepts tags.
Pre-send checks + exclusions (D:email-presend-checks, D:campaign-exclusions):
18. A template with “[LINK A DEFINIR]” in it: Pre-send checks shows an error, and the campaign
Send is refused naming it. A WebP image shows a warning.
19. Edit a reminder campaign → “Skip anyone who is also in” → pick a list you’re in → Send:
you don’t receive it; everyone else on the audience does. The list row shows “· skips 1”.
2026-10-05 — D:turnstile-published: turn Turnstile on (code merged, switch is yours)
Section titled “2026-10-05 — D:turnstile-published: turn Turnstile on (code merged, switch is yours)”Published pages can now draw the Turnstile widget on forms with spam.turnstile on (see
decisions-logs/entries/2026-10-05-turnstile-published.md). Nothing is enabled yet. To use it:
- Cloudflare dashboard → Turnstile → create a widget covering every published host.
wrangler secret put TURNSTILE_SECRET_KEY --cwd workers(never a plaintext var).- Add the PUBLIC site key as
"TURNSTILE_SITE_KEY"undervarsinworkers/wrangler.jsonc, deploy. - Republish the site, then switch
spam.turnstileon for the form (12x12 footerform-22first). Order matters: with the secret set but no site key, a flagged form 403s every visitor.
2026-10-06 — Turnstile: what is left for you (update to the 2026-10-05 entry)
Section titled “2026-10-06 — Turnstile: what is left for you (update to the 2026-10-05 entry)”State checked in Cloudflare: widget “Matriz-Turnstile” exists (site key now in workers/wrangler.jsonc),
and TURNSTILE_SECRET_KEY is already a secret on skyfall-api. Remaining:
- Turnstile → Matriz-Turnstile → Hostname management: add
matriz.page(subdomains included) and each custom domain that will use it (it only listsmatriz.designtoday; widgets cap hostnames, ~10 unless raised). - Test once deployed: republish, flag a form (or Members → Bot check), submit on the
matriz.pageURL. A 403 with the widget visible = the secret belongs to another widget; create a fresh one, set both keys from it. - Republish any site after switching a form’s Turnstile or the Members “Bot check” switch.
2026-10-18 — Motion Studio: what only you can do (D:motion-studio, D:motion-render-jobs, D:motion-audio)
Section titled “2026-10-18 — Motion Studio: what only you can do (D:motion-studio, D:motion-render-jobs, D:motion-audio)”The studio code is merged and safe with everything below undone: video jobs stay queued while
MOTION_RENDER_RUNTIME="off" (workers/wrangler.jsonc), model drafts are off (MOTION_DRAFTS="off"),
the audio library is empty. Stills (images, PDFs) work without any of this. In order:
- Apply the migrations per
db/MIGRATIONS.md: drizzleworkers/drizzle/0061_chief_goliath.sql(createsmotion_compositionsandmotion_render_jobs) AND the RLS sidecardb/migrations/20261018_motion_rls.sql. Neither is applied to prod; the Motion routes fail until the tables exist. - Decide which roles get Motion. Screens open by capability (
D:motion-studio-screens): Motion =structure:write(designer and up), Media and content =content:writeormarketing:write, Print =content:writeorstructure:write. There are no per-person grants and a content editor cannot open Motion. Say if you want something different (MOTION-screen-grants). - Set the monthly render cap. There is no UI control yet: an admin calls
PATCH /api/motion/budget?target_id=...(workers/src/lib/motion-budget.ts; stored inprojects.settings.motion; the client isfrontend/src/lib/api/motion.ts). Over the cap the API answers 409budget_cap. A settings control isMOTION-budget-uiinplans/PENDING-WORK.md. - Source licensed or CC0 library tracks and append them to
MOTION_AUDIO_LIBRARY(packages/schema/src/motion-audio-library.ts), files under R2platform/motion-audio/. Keep the licence text and attribution per track. Until then only owner uploads exist (admin, with a licence confirmation). - Pin the base image digest in
apps/motion-render/Dockerfile:docker buildx imagetools inspect node:22.12.0-bookworm-slim, then append@sha256:...to theFROMline. - Set the shared secret on both Workers, same value, never printed or committed (generate one yourself):
wrangler secret put MOTION_RENDER_TOKEN --cwd apps/motion-render/edgeandwrangler secret put MOTION_RENDER_TOKEN --cwd workers. It is a SECRET, never avarsentry (a var shadows it). - Deploy the edge Worker (no workflow exists):
pnpm --filter matriz-motion-render run deploy:dry, read the output, thenpnpm --filter matriz-motion-render run deploy(builds the Chrome + FFmpeg image; run it from a machine with Docker). - Add the
MOTION_RENDER_ORIGINvar (not declared inworkers/wrangler.jsoncyet, only mentioned in a comment) onskyfall-apias the deployed edge Worker’s origin. - Flip
MOTION_RENDER_RUNTIMEto"container"only after ONE real short job succeeds end to end (queued, rendering, done, download plays, audio in sync). Record the measured seconds per frame and the cold start inplans/PENDING-WORK.md(MOTION-studio) so the estimator constants inpackages/schema/src/motion-cost.tscan replace the guesses. - Optional later: set
MOTION_DRAFTS="on"to open model drafts (native Workers AI, daily allowance).
Heads-up: download, share-file and confirm URLs are signed with a key derived from CLERK_SECRET_KEY
(D:motion-render-jobs). Rotating the Clerk secret invalidates outstanding Motion URLs and confirm
tokens (minutes-old at most); nothing else breaks.