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)”- Worktree per spec, branched off fresh
origin/master(git fetch origin masterfirst). Branch name given per spec. Never work in the main checkout, never reuse another spec’s worktree. - 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). - 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, plustest_element_mapper.py).packages/*changed ⇒ runpnpm --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).
- 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.
- Commit early and often. Session limits strike without warning; committed work survives, uncommitted work costs a salvage round.
- No parallel write paths (save-v2 sink only), kebab-case style keys (D56),
no string-concatenated SQL (Drizzle only), CSP invariant (
script-srcis forever'none'|sha256 — CI-gated), never make private tenant assets world-readable, never force-push master. - 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). Addrangeanddate-rangearchetypes. Each generates TWO endpoint children (min + max) carryingdata-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 incollection-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 handlesmin/maxattrs — seedata-mf-unsethonest-unset in the runtime),<input type="date">for date-range. Both are block-level chrome children markeddata-mf-option-ofso 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 thetypedOnlylogic 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
toggleby the runtime — the panel must NOT offer Mode for these archetypes (extendarchetypeUsesMode; 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 inhtml-serializer.ts— emit ONE root rule in the published base stylesheet:body { font-family: var(--font-primary, <existing fallback stack>) }(checkfont-roles.tsfor 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.htmlstatic CSS or thesetSiteCssbridge — 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.tsreads shipped bytes from both surfaces — clone that pattern asfont-primary-ground-parity.test.ts). - Live cascade: check the token panel’s write path (themeStore → token CSS →
setSiteCsspostMessage). Editing--font-primaryalready 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.
S3 · Bondlayer styleguide link token becomes the site-wide link default — D:link-token-default · sonnet · ~0.5 day
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.pytoken emission; grep the capture corpus indocs/Research/bondlayer-editor-app/for which of the five carries the link role — likely namedlinkor 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 tsvectorGENERATED column over the item’s searchable text (datajsonb: 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.simpleconfig, NOTenglish— multilingual content).- GIN index on it.
db-guardwill gate drift — runpnpm --filter ./workers db:generateand commit the generated migration (this is the one spec where db-guard is in scope; do NOT hand-editworkers/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’smatchesConditionsemantics — 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.tswire 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:
- Locate the two collections in the capture corpus (
docs/Research/+apps/bondlayer-worker/srcfixtures; also the xlsx path — 8.1 used an xlsx of users). Inventory: row counts, field shapes, WHERE the email field is, whatusers foldersactually is (grouping? permissions? verify before assuming). - Verify the 8.4 refusal is what excludes them (grep the hard-refuse list in the importer; confirm both collection names/ids match it).
- Map fields → the member-seed input schema (
D:member-seeddocs/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). - 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):
- 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-snapshotskey 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. - 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. - No deletions. The proposal ends with the exact batched-delete commands
(
deleteKeysBatchedpattern) 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
cancelledearly-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
contentByTypefails 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 branchreview-archive-compile-r2). preview.htmlmessage 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.
- archive NEW-1 (F1 regression site untested: multi-collection fixture at the pass
site so re-narrowing
- 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/targetscoverage first, add thin missing listers only), Level-1 dashboard listing projects (ClerkOrganizationSwitcher— 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+ splituseEditorStore.currentSite→currentProject+currentTarget. THE BLAST RADIUS:getCurrentTargetIdandcurrentSiteare imported across most of the app. Mechanical strategy: (1) addcurrentTargetalongsidecurrentSiteas 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/:siteIdas 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 carriestarget_id. - 9.4 (
claude/phase9-org-ui, sonnet): mount ClerkUserProfile/OrganizationProfileroutes; 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):
- Reproduce locally: PGlite/miniflare publish test path first (
workers/test/publish suites — all green means the failure is environmental/deploy, not logic). - Trace the live path in order, eliminating layer by layer:
a.
POST /api/publish/:siteIdresponse in production (owner provides the error body/status — ask via the coordinator; do not guess). b. Worker deploy state: is productionskyfall-apirunning 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/:siteIdor the canonical host perPUBLISH_ROOT_DOMAIN). - 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.
- 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.
Parked (do not pick up without owner ask)
Section titled “Parked (do not pick up without owner ask)”- #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)”- S1 (range authoring) + S2 (font inherit) + S7 (R2 inventory, read-only) — wave 1.
- S3 (link token) + S4 (scroll_to) + S6 (users transform) — wave 2.
- S8 (consolidated review + follow-up PR) — after waves 1-2 merge.
- S5 (A-tier FTS, opus) — parallel to S8 if budget allows.
- S9 slices in order (9.1 → 9.2 → 9.3(opus) → 9.4 → 9.5).
- 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):
- Seeding sends NO email.
POST /api/targets/:id/members/bulkwrites rows; a member gets a magic link only aftermember-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. - 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.mdand initially recommended STAY; the owner later gave a reason to migrate anyway, which becameD:importer-containers(plans/IMPORTER-CONTAINERS-MIGRATION-SPEC.md). That migration’s Pass E cutover ran 2026-09-02:IMPORT_RUNTIME="container"is live onskyfall-api, and Render is retired as the importer host (decommission/Pass F still open, seeplans/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.pyon the realimports/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_RUNTIMEvar (render|container), defaulting torender, 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 deleteInvariants — each one is a test:
- 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). - Non-destructive. The source collection’s rows are byte-untouched by the pass
(pin: hash source items before/after).
retire_sourcesets asuperseded_bymarker 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. - Dry-run first, always.
POST /api/targets/:id/passes/previewreturns 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. - Tenant-scoped like everything else: target-scoped routes behind
projectAccessMiddleware; Drizzle only; writes batched. - References resolve within the target (D169 ids). An unresolvable ref promotes the
work WITH the asset but flags it in the census (
orphan_refscount) — never dropped silently, never guessed. - 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, withprojectAccessMiddlewareon the mount (workers/src/routes/import.ts:57); sibling routes/status/:targetId,/retry/:targetId,/analyze. apps/bondlayer-worker/src/api_writer.pyis deliberately scope-blind: it stripssite_idfrom 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.tsxnavigates to/editor/${siteId}in two places (~:755 post-import,:1221 the pending-import resume button), and provisioning hands back only:694) — no project id. Post-9.3 the canonical route issite_id(/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)”- 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. - Wire-format freeze — the highest-risk item.
/api/import/site/:targetIdand the sibling status/retry paths are a CONTRACT with a service that deploys from a DIFFERENT pipeline (matriz-import-runtime, auto-deployed bydeploy-import-runtime.ymlon merge sinceD: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.) - 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.
- 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 NULLorphaning incident). - 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.
- Async job contract.
packages/import/src/adapters/bondlayer.tsholds 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. - Grep sweep for stragglers: any remaining first-party construction of
/editor/:siteIdor 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) andpnpm --filter "./packages/*" run lintare 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.
- 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. _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.- A
_currentcontrol on a page with NO current item stays decorative and counted (D167 posture) — there is genuinely nothing to resolve. - Importer: re-emit
_currentheads 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. - 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.
GET /published-context/member: reads the__matriz_membercookie session, returns{id, slug, role}or an anonymous marker. Never a member field value — pinned by a test that asserts the response shape exhaustively. AbsentMEMBER_SESSION_SIGNING_KEY⇒ everyone anonymous (free degradation, existing gate).- Runtime (
collection-filter-runtime.ts): lazy single fetch on first paint of a page carrying a_usercontrol; 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. - Marker grammar gains one attribute (
data-mf-context="member"); importer re-emits_userheads with it. - This slice changes the interactions runtime ⇒ new pinned sha256 — the sanctioned
mechanism; update the CI-gated hash test in the same commit.
script-srcstays'none'|hashes; third-party ledger untouched (same-origin fetch). - 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)”- 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).
- 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. - Visibility rules v1: node-level audience conditions (anonymous / member /
role:
/ owner — item.ref_user == meinside repeater scope) + field conditions (member.<field> <op> <value>, schema-aware picker). Stored on the node like existing visibility state (followD:hidden-honest-canvas/D:bondlayer-state-triggersconventions — read them first). Evaluator lives in@matriz/site-render(shared), even though this slice only exercises it in preview. - 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.
- 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).
/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
useTokenSearchengine) + P3 (dropdownonCreate) — 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)
NewProjectModalClone (assessed 2026-07-14: needs backend) andRowDetail“View on site” deep link — CMS row-duplicate from the same item already shipped (acc77a1).