Skip to content

Loose-ends execution specs (2026-08-16)

Authored by the coordinator for delegated execution on smaller models. Each spec is self-contained: a sonnet-class agent should be able to execute it without re-deriving context. Opus is flagged only where the spec says so. Read CLAUDE.md first, always.

Global execution rules (apply to every spec)

Section titled “Global execution rules (apply to every spec)”
  1. Worktree per spec, branched off fresh origin/master (git fetch origin master first). Branch name given per spec. Never work in the main checkout, never reuse another spec’s worktree.
  2. Decisions: cite by the slug given in the spec (D:<slug>); NEVER assign a D-number; append at most ONE unnumbered ## D<n — claim at merge> DECISIONS.md entry. The coordinator numbers it at merge (claim-late rule in CLAUDE.md).
  3. Gates (CI-economy policy, owner-approved 2026-08-16): run ONLY the suites your diff touches, foreground, then push --no-verify; GitHub Actions on the PR is the gate. Two NON-NEGOTIABLE exceptions Actions does not cover:
    • apps/bondlayer-worker/ changed ⇒ run the Python suite (cd apps/bondlayer-worker/src && python3 test_<touched>.py, plus test_element_mapper.py).
    • packages/* changed ⇒ run pnpm --filter "./packages/*" run lint (0 errors required — this is the ONLY gate that catches the TS-template-literal \\→\ escape corruption; it has caught a real one).
  4. Foreground only. Never start a long command with run_in_background and end your turn — an ended turn with no live children never wakes (this killed four agents). If a command may exceed the shell cap, run per-suite chunks.
  5. Commit early and often. Session limits strike without warning; committed work survives, uncommitted work costs a salvage round.
  6. No parallel write paths (save-v2 sink only), kebab-case style keys (D56), no string-concatenated SQL (Drizzle only), CSP invariant (script-src is forever 'none'|sha256 — CI-gated), never make private tenant assets world-readable, never force-push master.
  7. Report format: what changed (file:line), tests added, gate output verbatim, pushed SHA, what you could NOT verify and why. Honesty over completeness — a named gap beats a papered-over one.

Merged context you can rely on (all on master): D179 filter authoring (Bar/Groups/ census/filtered links), D180 text-trim cascade, D181 archive compilation, D178 typed range/date runtime, D175-177 fidelity trio, D174 font pipeline, the repeater expansion chokepoint (#477), the CMS displayValue choke point (#478).


S1 · Range + date-range archetypes get their authoring surface — D:range-authoring · sonnet · ~1 day

Section titled “S1 · Range + date-range archetypes get their authoring surface — D:range-authoring · sonnet · ~1 day”

The gap: D178 shipped the runtime half of typed ranges (data-mf-bound="min|max" endpoints, data-mfn-<slug> typed values, <param>.min/.max URL grammar) but its ledger entry states “no authoring surface”. D179’s insert dialog + panel predate it: the archetype table has no range / date-range rows, so nothing an author can click emits a bound endpoint. The owner approved the FULL archetype set (range slider, date-range) in the design doc — this closes it.

Files:

  • packages/site-render/src/filter-controls.ts — the archetype table + the ONE generator (filterControlChildren / filterControlSelfAttrs). Add range and date-range archetypes. Each generates TWO endpoint children (min + max) carrying data-mf-bound="min"|"max", data-mf-filter=<listKey>, data-mf-field=<slug>, and for date-range the operand ops the runtime date-parses (see the §2d contract in collection-filter-runtime.ts — full ISO bounds restore server parity; year-numbers deliberately decline, divergence 4).
  • Endpoint element type: native <input type="range"> for numeric (the runtime’s resting-state capture at hydration handles min/max attrs — see data-mf-unset honest-unset in the runtime), <input type="date"> for date-range. Both are block-level chrome children marked data-mf-option-of so the expansion pass regenerates them (chrome is never persisted — D179 invariant).
  • frontend/src/components/property-editor/modules/FilterControlSection.tsx — the archetype picker gains the two rows; the field picker for them must filter to TYPEABLE fields only (number/date shapes — reuse the typedOnly logic added for F16; a range control on free text publishes inert, and the panel must refuse with the same amber pattern the fieldless case uses).
  • frontend/src/lib/filter-insert.ts + FilterBarInsertDialog.tsx — the §2 insert flow offers Range/Date-range checkboxes only when the host’s collection has a typeable field; seeding follows the F6 pattern (real field or disabled-with-reason).
  • Mode row: bound endpoints are forced toggle by the runtime — the panel must NOT offer Mode for these archetypes (extend archetypeUsesMode; it is the F3 gate).

Tests: generator emits the exact marker set per archetype (differential against what the runtime consumes — follow the filter-control-publish.test.ts pattern of running the REAL runtime over the generated DOM and asserting a range interaction writes <param>.min=); panel gating (typeable-only, no Mode row); insert seeding; census participation (a range-only list must count in filterUrlCensus — it owns params; add the fixture arm).

Hazards: the runtime file is a TS template literal — if you touch it AT ALL, packages lint is mandatory (global rule 3). You should NOT need to touch it: the runtime half is done. If you find you need a runtime change, STOP and report instead.


S2 · INHERIT grounds in the Primary Font token + live cascade — D:font-inherit-primary · sonnet · ~0.5-1 day

Section titled “S2 · INHERIT grounds in the Primary Font token + live cascade — D:font-inherit-primary · sonnet · ~0.5-1 day”

Owner report (verbatim intent): “On Bondlayer, the primary font is the default for all pages: elements with Inherit and no ancestor font inherit from the Primary Font setting. Changing the Primary Font in the token panel should cascade to all pages in real time.”

Diagnosis to confirm first (30 min): an element whose font-family is unset inherits up the DOM; if NO ancestor sets one, the chain grounds in the UA default — not --font-primary. The fix is to ground the chain at the root.

Files:

  • packages/site-render/src/token-presets.ts + the base-CSS emission in html-serializer.ts — emit ONE root rule in the published base stylesheet: body { font-family: var(--font-primary, <existing fallback stack>) } (check font-roles.ts for the exact var name + fallback; D54/D55 make the var names a contract — use the existing constant, do not restate the string).
  • Canvas parity: frontend/public/preview.html static CSS or the setSiteCss bridge — whichever carries the base rules today (find where the D175 link rule landed and put this beside it; that fix established the parity pattern AND its parity test shape: link-wrapper-colour-parity.test.ts reads shipped bytes from both surfaces — clone that pattern as font-primary-ground-parity.test.ts).
  • Live cascade: check the token panel’s write path (themeStore → token CSS → setSiteCss postMessage). Editing --font-primary already re-pushes token CSS to the canvas; since the new rule REFERENCES the var, the canvas updates with no extra work — verify with a test, don’t assume. Published pages update on republish (that is correct and expected — say so in the ledger entry, don’t try to make published pages live-update).

Specificity constraint: the rule must be zero-or-body-level so ANY authored font-family (inline base styles, state layers, imported literals) wins. body { } is (0,0,1) — fine. Do NOT use * (breaks monospace form controls and makes every element’s computed style churn).

Tests: parity test (both surfaces byte-carry the rule); a publish test asserting a font-family-less text node computes the primary var while an authored one is untouched; back-compat — pages with an explicit body font (imported sites author one) must be BYTE-IDENTICAL (the rule sits before authored CSS; imported sites’ own body/root font declarations override by order — pin that).

Hazard: imported Bondlayer sites already carry framework CSS setting body font — the new rule must lose to it (order + equal specificity ⇒ later wins; the base block is emitted BEFORE page CSS, verify). If any imported-site fixture changes bytes other than adding the one rule, stop and report.


Section titled “S3 · Bondlayer styleguide link token becomes the site-wide link default — D:link-token-default · sonnet · ~0.5 day”

Owner: on Bondlayer, one of the five primary color tokens is applied to ALL links unless overridden by the element’s own style. D175 only made injected wrappers inheritance-transparent (stopped UA blue); imported links should get their INTENDED color.

Files:

  • apps/bondlayer-worker/src/ — find where the styleguide’s five primary colors are captured (style_transformer.py / site_mapper.py token emission; grep the capture corpus in docs/Research/bondlayer-editor-app/ for which of the five carries the link role — likely named link or positionally identified in the styleguide JSON). Identify the link-role token and record its Matriz token var name.
  • Emit ONE site-level CSS rule at import (wherever imported global CSS lands — check how the framework html/body/screen{height:100%} chain from D:import-position-size was emitted; use the same channel): a, [data-matriz-link] { color: var(--color-<linkRole>) } at zero specificity (:where(a, [data-matriz-link])) so ANY element style overrides, matching D175’s pattern exactly.
  • Python only + one parity check that the emitted rule survives publish (the imported CSS channel already round-trips; add the assertion to the existing import-CSS test).

Tests: Python — styleguide fixture with a link-role color → rule emitted with the right var; styleguide WITHOUT a link role → no rule (absent ≠ default-blue; fail quiet). Ledger entry notes this only affects sites imported AFTER the next Render rebuild (owner handles rebuilds — do NOT add reminders anywhere).

Hazard: do not touch packages/site-render — this is an importer-emitted rule travelling the imported-CSS channel, not a renderer change. If the channel doesn’t exist as assumed, report rather than inventing a new one.


S4 · scroll_to behaviors no-op on root-scrollport pages — D:scroll-to-root · sonnet · ~0.5 day

Section titled “S4 · scroll_to behaviors no-op on root-scrollport pages — D:scroll-to-root · sonnet · ~0.5 day”

What it is: imported pages carry scroll_to behaviors (nav → scroll to an anchor element). An earlier layout fidelity fix moved page scrolling out of an inner container into the document scrollport on some pages; the behavior implementation still scrolls the INNER container, which no longer overflows — so clicks silently do nothing.

Files: grep scroll_to in packages/site-render/src/interactions-runtime.ts (published half) and the canvas behavior executor (frontend). Find the element it calls .scrollTo/scrollIntoView on.

Fix: resolve the ACTUAL scrolling ancestor at click time — walk up from the target element to the first ancestor with scrollable overflow (scrollHeight > clientHeight && overflow-y != visible/hidden), falling back to the document scrollport. element.scrollIntoView({behavior:'smooth', block:'start'}) already does this natively — prefer replacing manual container math with scrollIntoView unless the existing code has offset compensation (sticky headers); if it does, keep the offset but compute it against the found scroller. ES5 if in the published runtime — and then packages lint is mandatory (global rule 3).

Tests: runtime test with (a) inner-scroller fixture (old behavior preserved), (b) root-scrollport fixture (the broken case — must scroll document.scrollingElement), (c) sticky-header offset if the code has one. Canvas + publish parity if both halves exist separately.


S5 · A-tier: FTS column + published query endpoint — D:atier-fts · opus · ~2 days

Section titled “S5 · A-tier: FTS column + published query endpoint — D:atier-fts · opus · ~2 days”

Owner-approved (post-451). The design doc’s §9/A-tier: a live query endpoint for published pages whose collections exceed even the B+ archive tier, backed by real Postgres FTS.

Schema (packages/schema/src/schema.ts → drizzle migration):

  • content_items.fts tsvector GENERATED column over the item’s searchable text (data jsonb: string values only — write a SQL expression extracting text fields; simplest robust form: to_tsvector('simple', jsonb_extract_text_recursive) via a helper function in the migration, or a trigger-maintained column if generated-column expression limits bite. simple config, NOT english — multilingual content).
  • GIN index on it. db-guard will gate drift — run pnpm --filter ./workers db:generate and commit the generated migration (this is the one spec where db-guard is in scope; do NOT hand-edit workers/drizzle/).
  • Locale bags: extract ALL locale values into the vector (search any language).

Endpoint (workers/src/routes/): GET /published-query/:targetId/:listKey?q=&<field>=&sort=&page=

  • PUBLIC, no auth — published pages are public. SECURITY: it must serve ONLY targets that are actually published (join against the publish state; a draft-only target 404s), ONLY the collection bound to that list on that page (resolve listKey → collection binding server-side; the client never names a collection id), NEVER members-only collections (the D181 compile-boundary refusal is the pattern — refuse at the query layer too), and NEVER fields excluded from the item wire format (reuse the exact projection the publish pipeline uses for item data — one shared function, not a copy).
  • Filters: reuse collection-query.ts’s matchesCondition semantics — SQL-side via Drizzle builders (no raw SQL) for the ops it can express; decline (400) the rest. Rate-limit: per-IP token bucket via DO or a simple KV counter — 60 req/min is fine.
  • Response shape: the SAME item shape as B+ archive chunks (archive-index.ts wire format) so the runtime consumes both identically.

Runtime consumption is OUT of this spec — the endpoint + schema land first; wiring the published runtime to call it is a follow-up slice (keeps this reviewable). Say so in the ledger entry.

Tests: PGlite node tests for the endpoint (auth-less access to published target, 404 draft, 403/absent members-only, projection excludes non-wire fields, FTS matches across locale bags, pagination). db-guard green.

Why opus: public unauthenticated surface + schema migration + tenant-boundary reasoning. The security constraints above are the review surface.


S6 · users + users-folders → visitor-auth migration — D:users-migration · sonnet (investigation + wiring; owner-gated execution) · ~1 day

Section titled “S6 · users + users-folders → visitor-auth migration — D:users-migration · sonnet (investigation + wiring; owner-gated execution) · ~1 day”

Owner goal: the 12x12 capture has massive users and users folders collections, neither imported. Owner needs the EMAILS to prompt existing users to recover accounts under Matriz visitor auth (set new password).

Critical context (do NOT treat absence as a bug): the importer HARD-REFUSES member/user collections by design (8.4 sensitive-data decision). The counterpart pipeline already exists: D:member-seed (members.json → native users + magic-link recovery) and the 8.1 xlsx wiring (12x12 users xlsx → member mapping). This spec CONNECTS the captured collections to that pipeline; it does not weaken 8.4.

Steps:

  1. Locate the two collections in the capture corpus (docs/Research/ + apps/bondlayer-worker/src fixtures; also the xlsx path — 8.1 used an xlsx of users). Inventory: row counts, field shapes, WHERE the email field is, what users folders actually is (grouping? permissions? verify before assuming).
  2. Verify the 8.4 refusal is what excludes them (grep the hard-refuse list in the importer; confirm both collection names/ids match it).
  3. Map fields → the member-seed input schema (D:member-seed docs/code — members.json shape). Write the transform as a SCRIPT (not an importer change): apps/bondlayer-worker/tools/users_to_members.py — capture json/xlsx in, validated members.json out, with a summary (count, missing-email count, duplicates).
  4. STOP THERE. Produce the members.json into the scratchpad, report the summary (counts only — never paste emails into the report/PR/ledger), and hand off: the OWNER runs the seed (magic-link dispatch is an outward-facing action). Document the exact command the owner runs, referencing the existing member-seed runbook.

Hard constraints: no PII in git, in PR bodies, in DECISIONS.md, or in agent reports — counts and field NAMES only. The transform script is committed; its output is not. Nothing sends email. Nothing writes to the live DB.


S7 · R2 cleanup — inventory first, owner approves deletions — D:r2-cleanup · sonnet · ~0.5 day, READ-ONLY

Section titled “S7 · R2 cleanup — inventory first, owner approves deletions — D:r2-cleanup · sonnet · ~0.5 day, READ-ONLY”

Owner: R2 should be cleaned of data not belonging to live projects.

Strictly two phases; this spec is ONLY phase 1 (read-only):

  1. Enumerate live scope: query the DB (Neon MCP is READ-ONLY diagnostics — SELECTs only) for live target ids + project ids. Enumerate R2: skyfall-assets + skyfall-yjs-snapshots key prefixes (list via the Worker’s R2 binding in a local script against the MCP R2 tools, or the CF API MCP — list only). Known prefix families: published/<targetId>/, builds/<targetId>/, published-versions/<targetId>/, backups/<targetId>/, redirects/<targetId>.json, <projectId>/ (uploaded media — PROJECT-scoped, shared across targets: an orphaned TARGET does not orphan its project’s media), yjs snapshot keys.
  2. Produce plans/R2-CLEANUP-PROPOSAL.md: per-prefix orphan counts + total bytes + the exact deletion list location (scratchpad file, not committed), flagging (a) the assets.target_id orphan population (95% of the table — those R2 objects belong to the PROJECT and may be referenced by other targets: cross-target union check REQUIRED before calling media orphaned — see the D181 F10 proposal in OWNER-OPS), (b) anything ambiguous as KEEP.
  3. No deletions. The proposal ends with the exact batched-delete commands (deleteKeysBatched pattern) the owner approves and a follow-up agent executes.

S8 · Consolidated post-merge review — one pass, ONE follow-up PR — sonnet (review) + sonnet (fix PR) · ~1.5 days

Section titled “S8 · Consolidated post-merge review — one pass, ONE follow-up PR — sonnet (review) + sonnet (fix PR) · ~1.5 days”

Owner-directed: everything merged under merge-first gets one consolidated review; findings land in a SINGLE follow-up PR.

Scope (exact list of merged-unreviewed or deferred items):

  • #477 repeater chokepoint (merged unreviewed) — focus: the expander registration lifecycle (register/deregister on mount/unmount vs multiple CanvasPanel instances), the cancelled early-return, cache-key change.
  • #478 displayValue (merged unreviewed) — focus: locale fallback order, reference resolution cost in table cells (N+1?).
  • #479 merge-resolution: the CanvasPanel import-conflict resolution + the census/ typed-range interaction on the merged tree (run the census parity + URL-params suites on master, confirm green — they were green pre-merge).
  • Deferred residuals, all named in prior reviews:
    • archive NEW-1 (F1 regression site untested: multi-collection fixture at the pass site so re-narrowing contentByType fails a test), NEW-4 (F5 grammar corpus: add .., encoded, absolute, backslash, empty, long-key shapes), NEW-5 (see the verification notes file: scratchpad/wt-arcverify/REVIEW-NOTES-archive-compile-r2.md — commit 17701c02 on branch review-archive-compile-r2).
    • preview.html message handler try/catch (a render throw freezes the canvas silently — add the guard + a postMessage error report to the parent).
    • wrapper + non-chrome sibling → wrong template child (consistent bug, repeater resolver repeater-wrapper.ts:420-563) — fix the shape recognition or refuse loudly.
    • dead useExpandedDomTree — delete it (its stale-guard is broken anyway).
    • text-trim N2 (cascade-hint rendered-copy pin + memoize hasTrimmableDescendant’s index) and the F16 warning pinned as rendered output, not just a flag.
    • CMS image previews: production datum branch — IF the owner reports 401 (cookie not reaching api.matriz.design), fix = same-origin serve URLs via the Pages proxy; IF 404, fix = size-variant backfill job (~309 rows, script). Ask the coordinator which datum arrived; skip if none.
  • Verdict style: findings with file:line + failure scenario; apply ALL fixes in the same branch (claude/consolidated-followups); one PR.

S9 · Phase 9 — Projects/Targets frontend hierarchy — slice specs · 9.1-9.2 sonnet, 9.3 opus, 9.4-9.5 sonnet · ~1-2 weeks total, ONE SLICE PER PR

Section titled “S9 · Phase 9 — Projects/Targets frontend hierarchy — slice specs · 9.1-9.2 sonnet, 9.3 opus, 9.4-9.5 sonnet · ~1-2 weeks total, ONE SLICE PER PR”

Source: archive/2026-09-13-doc-rework/plans/EDITOR-COMPLETION-PLAN.md Phase 9 + archive/2026-08-22-doc-reset/docs/reference/PIVOT-PUNCHLIST.md §2. The largest FE refactor; the store split (9.3) is the dangerous part. Execute in order; each slice is its own branch + PR; do NOT start 9.3 until 9.1+9.2 are merged.

  • 9.1 (claude/phase9-dashboard, sonnet): getProjects()/getTargets() API clients (routes exist server-side — verify /api/projects + /api/targets coverage first, add thin missing listers only), Level-1 dashboard listing projects (Clerk OrganizationSwitcher — org IS the project), target cards under each. Existing dashboard lists targets flat; keep it working behind the new level (route / shows projects; clicking through preserves ALL current card actions — publish, traffic, delete). Tests: dashboard render with multi-project fixture; switcher org change refetches.
  • 9.2 (claude/phase9-project-view, sonnet): Level-2 project view — tabs for Data (CMS collections project-wide), Assets (the project-scoped media library — assets are PROJECT-keyed, this view is where that finally becomes visible), Design Systems, Deploy Targets (the target list + create). Reuse existing panels; this is composition, not new surfaces.
  • 9.3 (claude/phase9-store-split, opus): route /projects/:projectId/targets/:targetId/editor + split useEditorStore.currentSite → currentProject + currentTarget. THE BLAST RADIUS: getCurrentTargetId and currentSite are imported across most of the app. Mechanical strategy: (1) add currentTarget alongside currentSite as an alias first, (2) migrate importers file-by-file in ONE commit series with the old name kept as a deprecated getter, (3) flip the route, (4) remove the alias LAST, in the same PR, when grep shows zero consumers. Keep /editor/:siteId as a redirect (bookmarks + deep links). The save-v2 sink’s mandatory target scope param (D50) must be re-verified end-to-end after the split — that is the one place a rename slip silently loses edits: pin it with a test that a batch flush after the split still carries target_id.
  • 9.4 (claude/phase9-org-ui, sonnet): mount Clerk UserProfile/ OrganizationProfile routes; invite flows are Clerk-hosted — this is mostly routing + chrome.
  • 9.5 (claude/phase9-naming, sonnet): “site”→“target” sweep LAST (after 9.3 proves stable), following the CLAUDE.md naming-drift rule: rename only what 9.3 already touched + UI-visible strings; do NOT churn wire formats, DB columns, or historical docs.

S10 · Sites not publishing — end-to-end diagnosis — D:publish-broken-2 · opus · DO THIS LAST (owner sequencing)

Section titled “S10 · Sites not publishing — end-to-end diagnosis — D:publish-broken-2 · opus · DO THIS LAST (owner sequencing)”

Owner: “sites are not publishing at all.” Deliberately sequenced after everything above is merged (publish surface churned this week: D179 expansion pass, D181 archive compile hook, both in the publish path — the diagnosis must run against FINAL master).

Runbook (diagnosis before any fix):

  1. Reproduce locally: PGlite/miniflare publish test path first (workers/test/ publish suites — all green means the failure is environmental/deploy, not logic).
  2. Trace the live path in order, eliminating layer by layer: a. POST /api/publish/:siteId response in production (owner provides the error body/status — ask via the coordinator; do not guess). b. Worker deploy state: is production skyfall-api running current master? (Workers Builds MCP: workers_builds_list_builds — look for FAILED builds since the last known-good publish; a red build means production runs OLD code while tests run new.) c. DO flush step (SiteDurableObject) — check the v3 DO migration state (wrangler.jsonc:79-82): a Worker deployed WITHOUT the DO migration throws on DO access — a known activation gap (Phase 11.1 note). d. The D181 archive hook: it must be size-gated no-op for small sites and write-failure-degrading for large ones — verify the publish response’s error isn’t the archive path throwing pre-guard. e. R2 writes (published/<targetId>/), then serve (/published/:siteId or the canonical host per PUBLISH_ROOT_DOMAIN).
  3. The Cloudflare observability MCP servers need OAuth the sessions don’t have — production logs come from the owner (dashboard link) or the Workers Builds MCP; say which datum you need rather than guessing.
  4. Fix = minimal, on a branch, with a regression test at the layer that broke.

Why opus: cross-layer (frontend → Worker → DO → R2 → serve), live-production reasoning, and the failure may be an interaction of two just-merged features.


  • #44 CI runners cancelled at exactly 15m02s — infra-side runner allocation; re-check only if it recurs on current Actions.
  • Y.Text activation (Phase 10) — owner-controlled rollout, never autonomous.

Suggested execution order (respecting ≤3 concurrent, sonnet-first)

Section titled “Suggested execution order (respecting ≤3 concurrent, sonnet-first)”
  1. S1 (range authoring) + S2 (font inherit) + S7 (R2 inventory, read-only) — wave 1.
  2. S3 (link token) + S4 (scroll_to) + S6 (users transform) — wave 2.
  3. S8 (consolidated review + follow-up PR) — after waves 1-2 merge.
  4. S5 (A-tier FTS, opus) — parallel to S8 if budget allows.
  5. S9 slices in order (9.1 → 9.2 → 9.3(opus) → 9.4 → 9.5).
  6. S10 (publish diagnosis, opus) — LAST, per owner.

S11 · Members become an opt-in step of the import wizard — D:member-optin · sonnet · ~1 day

Section titled “S11 · Members become an opt-in step of the import wizard — D:member-optin · sonnet · ~1 day”

Why this exists. D182 shipped a standalone CLI (apps/bondlayer-worker/tools/users_to_members.py) because the importer hard-refuses member collections (8.4). That refusal was written BEFORE the member pipeline existed (D:member-seed, D:members-from-xlsx) and was never revisited once there was a safe destination. The owner’s question — “why can’t the importer do that?” — is correct: it can, and the hand-run script should stop being the only path. 8.4 is not repealed; it is given an explicit, defaulted-off, per-import consent gate.

Two facts that shape the design (verify both before building):

  1. Seeding sends NO email. POST /api/targets/:id/members/bulk writes rows; a member gets a magic link only after member-settings {enabled, joinMode} is on and they request one. So the irreversible/outward-facing step is already separate and stays a deliberate owner action — this spec does NOT automate invitations.
  2. Re-import multiplies. Each import creates a fresh target (D169), so an automatic seed would duplicate all 416 members per re-import. Consent must therefore be per-import AND the write must be idempotent per (target, source email).

Build:

  • Wizard step (frontend/src/pages/import/…, the universal import wizard): after the capture is analyzed and BEFORE the import runs, if the capture contains member-shaped collections, show a step: counts only (416 member records, 331 linked items, 2 duplicate emails across 4 rows), the field names being mapped, an explicit default-OFF checkbox (“Import members — no emails are sent”), and a plain sentence saying invitations are a separate step you take later. NEVER render an email address in the wizard, logs, or any artifact.
  • Detection + census reuse member_mapper.py’s existing logic via the importer service — do not reimplement. The wizard needs a pre-flight census endpoint (counts only) so the step can render before committing.
  • Wire: the wizard’s choice travels as an explicit boolean on the import request; the importer maps members ONLY when it is true; absent/false keeps today’s exact hard-refuse behavior byte-for-byte (pin that with a test).
  • Write path: importer → the existing bulk-members endpoint, scoped to the new target, idempotent (re-running the same import must not double-insert; key on (target_id, source email)). The importer service must NOT gain broad identity-table access — it calls the same scoped endpoint the CLI does, with the same validation.
  • The CLI stays (D182) as the out-of-band path for captures already imported.

Tests: consent-off byte-identical to today’s refusal; consent-on maps N records with the census matching the CLI’s numbers on the same fixture; idempotency (same import twice → one member set); no PII in any log line or response body (assert on the emitted payloads, not by eyeball); duplicate-email rows surface as a count, never a list.

Ledger: state plainly that 8.4’s blanket refusal is now a defaulted-off consent gate, why (the destination exists now), and what remains manual (invitations).


S12 · Importer runtime: evaluate Cloudflare Containers, keep Render, make it a choice — D:importer-runtime · sonnet (evaluation) → owner decides → sonnet (seam) · ~1 day for phase 1

Section titled “S12 · Importer runtime: evaluate Cloudflare Containers, keep Render, make it a choice — D:importer-runtime · sonnet (evaluation) → owner decides → sonnet (seam) · ~1 day for phase 1”

Status (2026-09-14): DONE, superseded. Phase 1’s evaluation shipped as archive/2026-09-13-doc-rework/plans/IMPORTER-RUNTIME-EVALUATION.md and initially recommended STAY; the owner later gave a reason to migrate anyway, which became D:importer-containers (plans/IMPORTER-CONTAINERS-MIGRATION-SPEC.md). That migration’s Pass E cutover ran 2026-09-02: IMPORT_RUNTIME="container" is live on skyfall-api, and Render is retired as the importer host (decommission/Pass F still open, see plans/OWNER-OPS.md) — this is more than the DUAL-RUN seam Phase 2 below describes (Render is not being kept as a live parallel backend, only as an inert rollback value). The spec below is kept for the evaluation methodology, not as a live TODO.

Owner framing: “the importer might run better in a Cloudflare container rather than Render, idk, maybe too much rewiring needed. Or before migration, keep both and allow choice between them.” That is exactly the right shape: do not migrate; add a seam, measure, then let the owner switch per-import.

Precedent to copy, not invent: apps/compile-worker/ (D181) is already a Cloudflare Containers seam in this repo — read its README first. Its two load-bearing properties are the pattern to reuse: (a) the container holds NO logic, it is ~100 lines of HTTP around code that lives in a shared package, so there is no second implementation to drift; (b) it is inert until an env var names it (ARCHIVE_COMPILE_URL absent ⇒ never called), so shipping the seam changes nothing until an owner opts in.

Phase 1 — evaluation, produces a costed doc, NO migration: Write archive/2026-09-13-doc-rework/plans/IMPORTER-RUNTIME-EVALUATION.md answering, with evidence from the actual code and the real captures:

  • Resource envelope: what does an import of the 12x12 capture actually consume? Measure peak RSS and wall time for parser.py → mappers → api_writer.py on the real imports/12x12/ fixtures. Compare against Cloudflare Containers instance limits (memory/CPU/duration for the available instance types) and against Render’s current plan. This is the decisive number — if the importer’s peak exceeds what a CF container instance offers, the answer is “stay on Render” and the doc says so in one line at the top.
  • Duration: how long does a full import take end-to-end, and does that fit a request-scoped container invocation, or does it need the async/job shape the wizard already uses (chunked import, D:import-chunking)?
  • Cold start: Render keeps a warm service; containers scale to zero. What does a cold-start import look like for the wizard’s UX?
  • What actually gets rewired: the importer is fronted by the D:import-proxy Worker route, so the CALLER already speaks HTTP to a URL — enumerate exactly which files change (likely: an env var, a client base-URL, a Dockerfile target), and what does NOT.
  • Python on Containers: confirm the existing Dockerfile runs as-is or name the delta.
  • Recommendation: MIGRATE / DUAL-RUN / STAY, with the number that decides it.

Phase 2 (only if the owner picks DUAL-RUN or MIGRATE) — the seam:

  • A IMPORT_RUNTIME var (render | container), defaulting to render, resolved server-side in the proxy route; absent/unknown ⇒ render, byte-for-byte today’s path.
  • Per-import override so the owner can A/B a single import from the wizard’s advanced section (same defaulted-off consent idiom as S11’s member step).
  • The container entry mirrors compile-worker: thin HTTP, same Python package, one Dockerfile, no forked logic.
  • Both backends answer the SAME contract; one integration test runs the same fixture through both and asserts byte-identical Site JSON output. That test is the whole point — it is what makes “keep both” safe rather than two divergent importers.

Do NOT decommission Render in this spec, and do NOT touch the Bondlayer mappers.


S13 · Data Passes — restructure imported collections without destroying them — D:data-passes · engine opus, UI sonnet · ~2-3 days total, TWO slices

Section titled “S13 · Data Passes — restructure imported collections without destroying them — D:data-passes · engine opus, UI sonnet · ~2-3 days total, TWO slices”

Owner’s problem, verbatim shape (12x12): users_folders (331 rows) is a Bondlayer workaround — a join-entity holding up to 12 image slots plus ref_user and ref_fase (festival edition), because Bondlayer cannot attach an image directly to an artist. An image inside a slot has NO identity: it cannot be referenced, filtered, published as content, or shown to its artist in a user area. Some artists have folders in BOTH editions. The owner needs to “rearrange data in passes: do away with folders without destroying the data, re-linking images directly to users with datetime stamps or tags.”

Design (settled — do not re-litigate): images are PROMOTED to first-class content_items in a works collection; the folder collection is RETIRED (hidden), never deleted; provenance makes the transformation traceable and idempotent. Assets are project-scoped already, so promotion re-references the same asset id — zero file copies.

S13a — the pass engine (opus, claude/data-passes-engine)

Section titled “S13a — the pass engine (opus, claude/data-passes-engine)”

One server-side engine, one pass type to start: promote_assets_to_items.

Config (persisted, so a pass is re-runnable and auditable):

{ source_collection, asset_fields: [..], // the image_img_N slots, discovered not hardcoded
target_collection, // existing or created (fields synthesized)
lift_references: [{from_field, as_field}], // ref_user → artist (stays a REFERENCE)
derive_tags: [{from_field, as_field, mode}], // ref_fase → edition ('reference'|'flatten_to_text')
carry_timestamps: true, // source item created_at → work created_at when present
retire_source: bool } // hide (supersede) the source collection, never delete

Invariants — each one is a test:

  1. Idempotent by provenance. Every promoted item carries data._provenance = {pass_id, source_item_id, source_field}; the write upserts on (target, source_item_id, source_field). Re-running a pass updates, never duplicates. An EMPTY slot promotes nothing (331 folders × 12 slots ≠ 3,972 works; only non-empty).
  2. Non-destructive. The source collection’s rows are byte-untouched by the pass (pin: hash source items before/after). retire_source sets a superseded_by marker on the content type (schema: nullable column or settings-jsonb key — prefer the settings key to avoid a migration) that hides it from CMS nav + pickers by default, with a “show retired” toggle; publishes/queries of it keep working.
  3. Dry-run first, always. POST /api/targets/:id/passes/preview returns the census: items to create/update/skip, per-slot counts, unresolvable references (a folder whose ref_user points at nothing), sample mappings (ids + field names, NEVER PII values). The execute endpoint requires the preview’s census hash — stale preview ⇒ 409 re-preview.
  4. Tenant-scoped like everything else: target-scoped routes behind projectAccessMiddleware; Drizzle only; writes batched.
  5. References resolve within the target (D169 ids). An unresolvable ref promotes the work WITH the asset but flags it in the census (orphan_refs count) — never dropped silently, never guessed.
  6. Members interplay: if the source collection is members-only/sensitive (8.4 class), the pass REFUSES unless the target collection is too — a pass must not be a laundering path from a gated collection into a public one. Test both directions.

12x12 acceptance fixture: a folders-shaped fixture (two editions, one artist in both, empty slots, one dangling ref_user) → works collection where per-artist queries return their works across both editions, edition tags distinguish them, and re-running is a no-op. Use capture-SHAPED synthetic data, not the real capture (no PII in fixtures).

S13b — the UI (sonnet, claude/data-passes-ui, ONLY after S13a merges)

Section titled “S13b — the UI (sonnet, claude/data-passes-ui, ONLY after S13a merges)”

Lives in the Level-2 project view’s Data tab (D191): a “Restructure” action on a collection row opens a pass wizard — source fields auto-discovered (asset-typed fields pre-checked), reference/tag lifts picked from the source’s reference fields, target collection picker (or “create works”), then the DRY-RUN CENSUS as the confirmation screen (counts, orphan refs, skip reasons — the census IS the consent UI, same idiom as S11’s member step), then run + report. Retired collections render collapsed with a “superseded” chip and a show-toggle. Counts and field names only — never render field VALUES in the wizard (the source may be a members collection).

Do not build pass type #2 (bulk re-link of references) in these slices; name it in the ledger as the follow-up the engine’s config shape was designed to accommodate.


S14 · Importer re-point audit after Phase 9 — D:importer-repoint · sonnet · ~1 day · GATED: do not start until 9.5 is merged

Section titled “S14 · Importer re-point audit after Phase 9 — D:importer-repoint · sonnet · ~1 day · GATED: do not start until 9.5 is merged”

Owner’s ask: once Phase 9 fully lands, sweep the importer for loose ends and “re-point any missing gaps”. Phase 9 moves the frontend from a flat target list to project → target, flips the editor route (9.3) and sweeps site→target naming (9.5). The importer is the one flow that writes ACROSS the whole hierarchy from three independent places — the wizard (React), the Worker routes, and the Python service deployed separately (the matriz-import-runtime Cloudflare Container since D:importer-containers; formerly Render) — so it is where a hierarchy refactor leaves gaps that typecheck cleanly and fail in production.

Deliverable: archive/2026-09-13-doc-rework/plans/IMPORTER-REPOINT-AUDIT.md (findings, each with file:line evidence and a verdict of FIXED / OWNER-ACTION / NOT-A-GAP) plus the fixes themselves in the same PR. Anything that needs an importer container redeploy is flagged OWNER-ACTION — never assumed done.

Verified pre-work (done by the coordinator; confirm, don’t redo)

Section titled “Verified pre-work (done by the coordinator; confirm, don’t redo)”
  • Worker import routes are already :targetId-named internally and mounted at /api/import/site/:targetId, with projectAccessMiddleware on the mount (workers/src/routes/import.ts:57); sibling routes /status/:targetId, /retry/:targetId, /analyze.
  • apps/bondlayer-worker/src/api_writer.py is deliberately scope-blind: it strips site_id from every entity (breakpoints, states, presets, component styles, fonts, assets, pages, content types, content items) because “the server stamps tenant”. The tenant travels ONLY in the URL path segment. This is what makes the Python side immune to frontend renames — confirm the property still holds; if any post-9.5 change makes the Python payload carry tenancy again, that is a finding.
  • The wizard’s exit is a live gap: ImportWizard.tsx navigates to /editor/${siteId} in two places (~:755 post-import, :1221 the pending-import resume button), and provisioning hands back only site_id (:694) — no project id. Post-9.3 the canonical route is /projects/:projectId/targets/:targetId/editor.

The audit (each item: evidence, verdict, fix-or-flag)

Section titled “The audit (each item: evidence, verdict, fix-or-flag)”
  1. Wizard exit re-point. Route the two navigate() calls to the canonical nested route. The wizard needs a project id it currently never receives: either have provisioning return it, or read the active org. Pick ONE and say why. Leaving the wizard on the 9.3 legacy redirect forever is NOT acceptable — the redirect exists for user bookmarks, not for first-party code.
  2. Wire-format freeze — the highest-risk item. /api/import/site/:targetId and the sibling status/retry paths are a CONTRACT with a service that deploys from a DIFFERENT pipeline (matriz-import-runtime, auto-deployed by deploy-import-runtime.yml on merge since D:importer-containers; formerly Render, redeployed manually per merge). A 9.5-style rename of those path strings breaks production silently until someone redeploys. Verify 9.5 did not touch them, and leave behind a guard test that pins the literal path strings with a comment naming the importer’s separate deploy coupling. (Same class of guard as the published-CSP and sandbox invariants.)
  3. Which project does an import land in? Under the new hierarchy an import started from a Level-2 project view must create its target in THAT project, not in whatever org happens to be active. Test the multi-project case.
  4. Assets are PROJECT-keyed, content is TARGET-keyed. Re-check that split survives the store rename end-to-end: importing a second target into a project that already has assets must not re-key, duplicate, or orphan them (this is the seam that already produced the ON DELETE SET NULL orphaning incident).
  5. Re-import of the same site. The owner reports the first import works and a second import of the same site does not. Repeater oscillation had three root causes fixed earlier; verify re-import specifically under the NEW hierarchy and report whether anything remains. If a real defect survives, that is its own follow-up spec — describe it precisely rather than fixing it inside this audit.
  6. Async job contract. packages/import/src/adapters/bondlayer.ts holds a 10-minute poll deadline over D115’s chunked ingest; the Worker proxy (import-worker.ts, D:import-proxy) speaks to a configured origin/token pair. Confirm the poll still targets live route shapes post-rename, and that a target id renamed in the frontend still resolves through the proxy.
  7. Grep sweep for stragglers: any remaining first-party construction of /editor/:siteId or of import URLs from renamed variables.
  • CI economy: run only the suites your diff touches — but the Python importer suite (cd apps/bondlayer-worker/src && python3 test_*.py) and pnpm --filter "./packages/*" run lint are MANDATORY if you touch those paths.
  • No PII in the audit doc, tests, or fixtures — counts and field names only.
  • Ledger entry ## D:importer-repoint (slug heading; the coordinator claims the number).

S15 · Filter context primitive — D:filter-context · two slices · restores the 31 sentinel controls (row 20)

Section titled “S15 · Filter context primitive — D:filter-context · two slices · restores the 31 sentinel controls (row 20)”

Doctrine, stated first and pinned by test: context filters are UX, never access control. A client-side filter operates on items already shipped in the HTML. The existing draft-gating (member rows never publish) remains the only privacy boundary; this primitive only selects within what is already public. The worst a broken context filter may ever do is show too much of what was already public.

The 31 sentinels split 27 × _current/_current:multiRef_* (current item) and 4 × _user (signed-in member). Two mechanisms, not one.

S15a — item context, compile-time substitution · opus · ship FIRST

Section titled “S15a — item context, compile-time substitution · opus · ship FIRST”

When the publish compiler renders a detail page for item X, a control comparand _current:<field> substitutes X’s actual value at compile time and becomes an ordinary literal filter. No runtime change, no endpoint, no CSP-hash change.

  1. Substitution lives in the SHARED expander (packages/site-render/src/ filter-control-expand.ts), which runs in both preview and publish — G3 parity holds by construction, and the canvas previewing a detail item shows the same behaviour. Never fork it per consumer.
  2. _current:multiRef_* substitutes to a value LIST and rides the existing multi-mode operator vocabulary (D160/D162). No new operator unless one is genuinely missing — if so, STOP and report rather than inventing semantics.
  3. A _current control on a page with NO current item stays decorative and counted (D167 posture) — there is genuinely nothing to resolve.
  4. Importer: re-emit _current heads as context predicates instead of D167’s provenance-only drop (element_mapper.py ~708/3434, filter_control_runtime_sentinel). The D167 recording stays for heads S15 does not support.
  5. Tests: substitution correctness incl. multiRef lists; parity (same expansion in preview and publish); the no-current-item degradation; importer emission. Python suite mandatory if apps/bondlayer-worker/ is touched.

S15b — member context, identity-only runtime · opus · AFTER S15a merges

Section titled “S15b — member context, identity-only runtime · opus · AFTER S15a merges”

The 4 _user uses all compare a reference field against the signed-in member, so the runtime needs the member’s identity, nothing else.

  1. GET /published-context/member: reads the __matriz_member cookie session, returns {id, slug, role} or an anonymous marker. Never a member field value — pinned by a test that asserts the response shape exhaustively. Absent MEMBER_SESSION_SIGNING_KEY ⇒ everyone anonymous (free degradation, existing gate).
  2. Runtime (collection-filter-runtime.ts): lazy single fetch on first paint of a page carrying a _user control; controls arm on resolve. Anonymous ⇒ the control is HIDDEN (owner decision), with a data attribute so a theme can restyle later. Never filter-by-literal-sentinel.
  3. Marker grammar gains one attribute (data-mf-context="member"); importer re-emits _user heads with it.
  4. This slice changes the interactions runtime ⇒ new pinned sha256 — the sanctioned mechanism; update the CI-gated hash test in the same commit. script-src stays 'none'|hashes; third-party ledger untouched (same-origin fetch).
  5. The text-search-* family (D163) rides the same context attribute as a NAMED FOLLOW-UP — do not build it in this slice.

S16 · Member areas: editor preview-as + visibility control — D:member-preview · owner request

Section titled “S16 · Member areas: editor preview-as + visibility control — D:member-preview · owner request”

Owner’s ask, verbatim shape: preview user areas as editors; control what logged-in visitor-authed users can see; dynamic, modular, user-custom-schema-aware.

The honest two-tier model, stated up front because static publish forces it:

  • Tier 1 — ENFORCED: page-level gating (members_only / required_role, already served by the edge gate) and the /members/me/* surfaces. Server-side, real security.
  • Tier 2 — COSMETIC: member-conditioned show/hide of nodes on PUBLIC pages. The bytes ship in the HTML; the condition only hides them. Legitimate for UX (“welcome back” banner, a “mine” tab), illegitimate for secrets. The editor UI must LABEL which tier a rule is — an author who thinks a cosmetic rule is security is a breach waiting to happen.
  • Consequence of static publish: field-aware conditions cannot run on public pages (that would require shipping member fields to every visitor’s browser). Field-aware rules evaluate in editor preview and on server-gated member pages only; public pages support audience-kind conditions only (anonymous / member / role, from S15b’s identity payload). State this in the UI, not just the docs.

S16a — “Preview as” in the editor · opus · dispatch NOW (parallel with S15a)

Section titled “S16a — “Preview as” in the editor · opus · dispatch NOW (parallel with S15a)”
  1. A Preview-as selector in the editor’s preview/interactive mode: Anonymous · Signed-in member (generic) · a specific member (picked from the members collection via existing admin reads) · a synthetic member (schema-shaped fields editable in a small form — this is the “user-custom-schema-aware” part: the form is generated from the members collection’s OWN schema, dynamically, whatever fields the site defined).
  2. Impersonation is ENTIRELY editor-side state: build a MemberContext {id, slug, role, fields} and feed it to (a) the S15 context resolver, (b) the visibility evaluator, (c) a banner on members-only pages (“gated page — previewing as X”). No real member session is ever minted; no magic link is sent; publish and serve are untouched by this slice.
  3. Visibility rules v1: node-level audience conditions (anonymous / member / role: / owner — item.ref_user == me inside repeater scope) + field conditions (member.<field> <op> <value>, schema-aware picker). Stored on the node like existing visibility state (follow D:hidden-honest-canvas / D:bondlayer-state-triggers conventions — read them first). Evaluator lives in @matriz/site-render (shared), even though this slice only exercises it in preview.
  4. Tier labeling in the properties panel: every rule shows ENFORCED (page gate) or COSMETIC (public-page show/hide), with one sentence of explanation. A field condition on a public page shows “preview-only — will not apply on the published page” rather than silently half-working.
  5. Editors already have CMS read access to the members collection, so the picker leaks nothing new — but the preview UI must not display more member fields than the schema form needs, and NO member PII goes into fixtures or tests (synthetic members only).
  6. /ui-lab: seed a synthetic members schema + fixtures; screenshot the selector, the schema-generated form, and the tier badges.

S16b — published cosmetic tier · sonnet · AFTER S15b merges

Section titled “S16b — published cosmetic tier · sonnet · AFTER S15b merges”

Audience-kind conditions (anonymous/member/role ONLY) evaluated on public pages by the same runtime fetch S15b added; display:none semantics; every D215/CSP invariant untouched. Field-aware public conditions are REFUSED at compile with a recorded count, not silently dropped.

Explicitly out of scope (named so nobody invents them)

Section titled “Explicitly out of scope (named so nobody invents them)”

Per-member static rendering; shipping member field values on public pages; server-rendered member variants of public pages (that is the dynamic-publish workstream’s territory, plans/DYNAMIC-PUBLISH-*).


S17 · Carried forward from EDITOR-COMPLETION-PLAN.md before archiving (2026-09-13)

Section titled “S17 · Carried forward from EDITOR-COMPLETION-PLAN.md before archiving (2026-09-13)”

That plan (Phase 9 hierarchy, Phase 10 Y.Text, Phase 11 IDR/pgvector) is otherwise fully shipped or already tracked (Phase 9 landed as D191–D202; Phase 10/multiplayer is STATUS.md item 14; Phase 11.2 pgvector is superseded per archive/2026-09-13-doc-rework/plans/2026-07-02-pgvector-site-vocabulary.md’s header). Its Phase 4 property-module backlog and two Phase 12 polish items were never picked up elsewhere and are genuinely still open — one line each so they aren’t lost with the archive:

  • 4.1 Migrate the Effects + Layout property modules to the descriptor-array + <PropertyField> pattern (Typography module already migrated) — internal refactor, no user-visible change, gated by the property-editor suite.
  • 4.3 Advanced-CSS surfaces, still unbuilt: container-query variant rows, a :has()/nth structural builder, clip-path polygon point-dragging, a global logical-properties toggle — build container-query rows first (reuses the breakpoint-layer model).
  • 4.4 Token-input relevance P1 (consolidate onto one useTokenSearch engine) + P3 (dropdown onCreate) — P0/P2 already shipped.
  • 4.6 Track L3 tab de-cluttering (fold Position/Constraints into Layout) — subjective UX reorg, do behind design review, not autonomously.
  • 12.1 (remainder) NewProjectModal Clone (assessed 2026-07-14: needs backend) and RowDetail “View on site” deep link — CMS row-duplicate from the same item already shipped (acc77a1).