Skip to content

Recurring structures + live time — execution plan (umbrella slug `D:recurring-structures`)

Partly stale — verified 2026-09-27 (D:plans-staleness-pass-2): the header below undercounts what shipped — Wave 3 (D:recurring-setup-ui, 2026-09-14: RecurringSetupPane.tsx, MemberEditGateEditor.tsx, CollapseSlotsWizard.tsx) also landed on the same branch and merged via PR #682 (STATUS.md § In-flight), not just waves 0-2. Still open (per STATUS.md, not duplicated here): the owner has to actually run the runbook against the real 12x12 target — folds into the same S33 re-import blocker already in plans/PENDING-WORK.md, not a separate row.

Status: executing — 2026-09-13, rev 2 (owner decisions OD-A…E answered, §7). Waves 0, 1 and 2 built on branch claude/eager-planck-6wul5f (PR #682; all streams + integration; DECISIONS entries appended by slug, numbers claimed at final sync). As-built notes: migration is 0040; the browser verification pass ran from scratch scripts (no Playwright harness added to the repo — owner decision pending). Decision slugs below are cited by slug until each merges (CLAUDE.md D-number rule); the DECISIONS entries are appended by the wave that ships them. Owner call, before reading further: this plan changes the publish contract in ONE way — date-driven visibility and relative-date repeater filters stop being frozen at publish and are settled in the visitor’s browser with a server-corrected clock (§3). The CSP invariant (script-src 'none' | hashes) is untouched: the temporal module rides the existing single hash-pinned runtime. Every enforcement of time (submission gates, edit locks, period stamps) stays server-side and authoritative. Three new forms columns, one new targets.settings key, two new field types (schedule, rollup), one new element (table), one new flow trigger, one new flow step, one new pass kind. No table is added.


The 2026-09-13 forms audit (this session; test run: ingest, visitor-auth, member-data, member-uploads, form-ingest — 5 files, 83 passing) established that forms, multi-collection writes, per-input CMS field binding, member-stamped submissions, per-visitor quotas, owner-scoped reads/edits and single-file member uploads all work. It also established twelve gaps against the 12x12 festival case (three dated sign-up periods with different prices, 10–12 images per participant, editable until the period closes, admins must know who signed up in which period and copy their emails out):

# Gap Where it lives today
G1 Public-form file upload is DOM shape only; ingest rejects a multipart File frontend/src/lib/operations/insert-element.ts:674-678, workers/src/lib/form-ingest.ts:126-133
G2 No multi-image widget on published pages; member lists fill text only packages/site-render/src/interactions-runtime.ts (wireUploads one file; fillFields textContent)
G3 Date conditionals (before/after/between, @now) evaluate ONCE at publish; relative pre-filters (_today) use the build clock; no cron republishes binding-engine.ts:552-558, collection-query.ts:644-648, publish.ts:400
G4 No submission window on forms; no edit lock on member item PATCH packages/schema/src/schema.ts:735-771, routes/modules/member-data.ts
G5 No server-side period/price stamp; a hidden input is client-controlled routes/modules/forms.ts
G6 Inbox shows visitorId as a raw UUID; no member↔items view; CSV carries the raw id components/cms/SubmissionDetail.tsx, MembersPane.tsx, routes/content-items.ts
G7 Spam, success text, quota, scope, upsert have no UI; listForms/updateForm/deleteForm have zero call sites lib/api/forms.ts, AttributesProperties.tsx:301-320
G8 Imported 12x12 collection forms all target member-private collections → refused; 14/24 upload controls unnamed; form-upload maps to div apps/bondlayer-worker/src/form_mapper.py, element_mapper.py:112-114
G9 users_folders written twice under opt-in (public copy + draft member copy); seeded member items forced draft; duplicate folders per artist per year plans/OWNER-OPS.md:966, routes/members-admin.ts:556
G10 No “invite all members” send plans/MEMBER-AUTH-NEXT-WORKSTREAMS.md WS-1 (D:member-invite-send)
G11 A person is up to three unjoined rows (visitor, submission, audience) STATUS.md:145-160 (open owner fork)
G12 field/owner visibility rules never apply on published pages (only _audience) member-visibility.ts, D224
G13 No designable, exportable data table for admin grids (“copy all emails of this period”) or public comparison tables no table element; CSV export exists only in the CMS inbox
G14 Upsert identity is a single field; re-submission per (member, edition) has no atomic “replace my entry” path routes/modules/forms.ts:264-309 (documented not race-safe)

And it established that the owner’s instinct — “when an edition is created, a slot appears on the user” — is right as a felt outcome and wrong as schema mutation (users already carries the fossil: boolean_inscrito_2024/2025, boolean_fase_2/3, ref_folder_2024, ref_folder2025). The slot must be a row, not a column. What makes a row feel like a field is a small set of general features the codebase lacks (verified: no computed/rollup/reverse-reference field, no site-level variable, no schedule concept, flow triggers = form_submit | audience_join | manual only):

  1. Schedules — a container item carries an ordered list of dated periods with gates (submit / edit / show) and data (price); fully UI-edited, no code, no per-period collection.
  2. Symbols — a named, dated “current” pointer (@current.edition, @current.edition.period) that forms, filters, visibility and derive rules reference.
  3. Rollups — read-only reverse references computed at query time.
  4. Collection triggers + instantiation — “item created in X” spawns forms and pages from a template (periods no longer need it; forms/pages still do).
  5. Data tables — a designable, exportable table element bound to a collection, for admin grids and public comparison tables.

Plus the one platform capability all of it leans on: live time on published pages.

1. Decisions (each becomes its own DECISIONS entry, appended by the wave that ships it)

Section titled “1. Decisions (each becomes its own DECISIONS entry, appended by the wave that ships it)”
Slug Decision (one line) Wave
D:live-time Date-driven visibility rules and relative-date pre-filters are MARKED at publish (like _audience, D224) and SETTLED in the browser against a server-corrected clock from GET /api/m/:targetId/time; publish still pre-resolves with the build clock as the no-JS default. Enforcement of time never moves to the client. 0
D:periods A schedule field type holds an ordered list of periods { key, label, start, end, gates:{submit,edit,show}, data } on any container item; “the current period” is resolved by clock; a period’s gates are what forms, edits and visibility obey. “Phase” is a 12x12 label, not a system term. 0
D:form-gates forms gains opens_at, closes_at, closed_message and a derive rule list; a form may instead gate on @current.<symbol>.period.gates.submit; derived fields are stamped server-side (client values overwritten); member item edits obey the row’s period edit gate live (an admin extending a deadline applies immediately). 0
D:site-symbols targets.settings.symbols names dated pointers into a collection ({ edition: { collection, mode:'window'|'pinned', startField, endField, scheduleField?, itemId? } }); resolved server-side everywhere a request has a clock and echoed publicly (id/slug/title/dates/current period only) at GET /api/m/:targetId/symbols; referenced as @current.<symbol>[.<path>]. 0
D:upsert-identity A write target may declare upsertScope: [<field>…]; the identity is visitorId + scope values, stored in the existing quota_key column and enforced by its partial unique index with INSERT … ON CONFLICT DO UPDATE (race-safe, closes the documented caveat). Re-submission REPLACES scalar and array fields (an imageset is replaced, never appended). maxPerVisitor becomes a special case of the same key. 0
D:submission-identity A submission’s data.visitorId is joined to site_visitors on read; the inbox, the detail pane, the CSV, tables on role-gated pages, and a per-member items view show the person, never the UUID. 0
D:row-moderation An owner-scoped row is public (status:'published') and owner-editable at once; an admin sets it draft (or reviewState:'archived') to pull it from the public site without touching the member; the owner’s member list still shows it with its status. 0
D:rollup-fields A rollup field type is a declared reverse reference (from, where_field, optional group_by, limit), computed at publish, in the CMS and for member lists; never stored; single hop; pointer identity per D:ref-filter-identity; multiRef via array-contains. 1
D:data-tables A table element binds to a collection (or a rollup) with designable columns, sort/filter via the existing filter controls, static publish + A-tier paging, and client-side CSV/XLSX export; columns that join member identity render ONLY on members_only pages with a required_role, refused at publish otherwise. 1
D:collection-triggers FlowTrigger gains { on:'item_created', contentTypeId } and FlowStep gains { type:'instantiate', template } (forms, pages; items optional); idempotent by uuid5('instantiate:<flowId>:<itemId>:<index>'); reuses ensureForm. The table keeps its email_flows name; UI copy says “Flows” (OD-C). 2
D:imageset-upload A member upload input with multiple uploads sequentially through /members/upload and writes a JSON array into its paired hidden input; member lists fill img.src and clone per-array-element templates; the form creator wires file/image/image_collection fields automatically. Anonymous uploads stay refused (OD-B). 1
D:form-settings-panel Forms get a property-editor module (spam, success, quota, upsert scope, gates, derive, actions summary) over the existing PATCH /api/forms/:id. 1
D:slots-to-array New pass kind collapse_slots_to_array: N same-typed slot fields collapse into one image_collection on the same row; slot fields marked retired; census + census_hash re-consent as promote_assets_to_items. 1
D:folder-single-copy Under the member opt-in a satellite collection is imported ONCE; member-seed pass 2 UPDATES the public row with data.visitorId; one row per (owner, edition) — duplicates keep the latest _updated_at, the rest are marked duplicate_of and left draft; form_mapper binds a form to an opted-in satellite instead of refusing it. 1
D:member-invite-send Already specified — plans/MEMBER-AUTH-NEXT-WORKSTREAMS.md WS-1. Dispatched unchanged in wave 1. 1
D:recurring-structures The umbrella: container collection + schedule + symbol + participation join with upsert identity + rollups + tables + (optional) instantiation. Documented as the canonical pattern; 12x12 is its first application. 2

2. The universal shape (what every wave builds toward)

Section titled “2. The universal shape (what every wave builds toward)”
container editions one row per occurrence; carries a `schedule` field (periods)
symbol @current.edition resolved by date window or pinned; @current.edition.period = active period
join works one row per participant per edition: visitorId, edition, period, images[], …
identity upsertScope ['edition'] re-submitting replaces the row for this member + edition
gates period.gates.submit/edit/show what the form, the member PATCH and the temporal runtime obey
derive period, price, submitted_at stamped at ingest from the current period; the billing record
rollups users.participations works where ref_user = @item, grouped by edition
tables admin grid on a role-gated page: works of this edition + submitter email + period; export CSV/XLSX
live time public pages flip at the period boundary; lists filtered by @current.edition; per-edition detail pages

Use cases beyond 12x12 that are the same shape with renamed nouns — and what their periods are:

Domain Container Periods (gates) Join
Festival / open call edition early / regular / late (submit+edit; price) works
Conference year CFP open / review / notify / schedule frozen (submit, then edit off, show) talks
Course cohort cohort enrolment / add-drop / assignments / exam (submit, edit) enrolments
Sports league season registration / regular season / playoffs (submit, show) registrations
Awards / contest cycle entries / voting / results (submit, show results) entries
Ticketing / pre-orders event early-bird / regular / door (price; submit) orders
Hiring round round applications / interviews / offers (submit, edit, show) applications

None of them wants a column per occurrence, and every one of them wants the number of periods, the dates and the prices editable in a UI by a non-technical admin.

3. Live time (D:live-time) — the platform change

Section titled “3. Live time (D:live-time) — the platform change”
  • New public module route GET /api/m/:targetId/time → { "now": <epoch ms> }, Cache-Control: no-store, KV rate-limited (60/min/IP like published-query.ts:147). Mounted in routes/modules/index.ts.
  • Runtime: fetch once at load, skew = server.now − (t0 + t1)/2; liveNow() = Date.now() + skew; fallback skew 0. Same-origin → connect-src 'self'.
  • Renderer: BindingEngineOptions.now?: number (new). parseRuleDate('@now') reads options.now ?? Date.now(). Publish threads buildNow (publish.ts:400) through page-render.ts. The “no clock injected” corner (binding-engine.ts:545-551) closes.
  • classifyVisibilityRule (member-visibility.ts) gains a fourth kind temporal: operator ∈ {date_eq,before,after,between}, or operand @now / relative token / @current.<symbol>.period….
  • Publish (resolveNodeBindings conditionals block, binding-engine.ts:1466-1531): temporal rules → { kind:'when-marker', encoded, hiddenDefault } with hiddenDefault = evaluation at buildNow. Marker write beside the audience case (:1578-1588).
  • Grammar: sibling namespace MF_WHEN = { rules: 'data-mf-when' } (collection-marker-grammar.ts, pattern MF_AUDIENCE :458-466); MF_RUNTIME_WRITTEN.whenState = 'data-mf-when-state' (pending | live). Declared in the orphan sweep; never in IMPORTER_EMITTED_MARKERS.
  • Runtime (collection-filter-runtime.ts, beside prepAudienceEls/settleAudience :2095-2118): prepWhenEls, settleWhen(els, now, symbols); then a single timer re-arms for the nearest rule/period edge (max 24h) so an open page flips at the boundary.
  • Period gates as visibility: data-mf-when payload may reference @current.edition.period.gates.show or .key == 'late'; the runtime resolves it from /symbols (lazy, only when referenced).
  • Embed trigger: interactions-runtime.ts:2310 predicate += hasTemporalMarkers. One hash, unchanged invariant.
  • Breakpoint-scoped temporal rules: same stated limit as audience (:1533-1564).

3.3 Deferred pre-filters (OD-A: sensible limits)

Section titled “3.3 Deferred pre-filters (OD-A: sensible limits)”
  • collection-query.ts: partitionPreFilters(expr) → { static, deferred } (deferred = relative token, @current.*, or date_* over either). applyCollectionQuery applies only static when options.deferTemporal.
  • repeater-expand.ts (:1004): emit survivors (all items already carry data-mfn-<field>, :635-673), stamp the list root data-mf-when-filter + data-mf-when-limit. Ceiling WHEN_DEFER_MAX_ROWS = 500; above it, the list publishes static (build clock) with a dev-warn, and the author is pointed at the A-tier live list (below) or a per-edition page.
  • Runtime: applyWhenFilter(list) hides non-matching items, re-applies the limit, toggles the empty-state marker. Relative token grammar ported from resolveDateValue with a parity test; divergence #3 (:277-279) deleted.
  • A-tier: lib/published-query.ts:35-42 accepts date_*, between-over-dates, relative tokens and @current.* (server-resolved). created_at/updated_at filterable with date ops.
  • The recommended public shape for 12x12 is NOT a deferred filter at all: per-edition detail pages (detail templates exist) and lists filtered by edition = @current.edition, which is one static value at publish plus one symbol lookup — periods are internal billing tooling.
  • “Preview as” (D222) gains “Preview at” (date-time) → editorStore.previewAt → engine now and preview.html mirror applyWhenPreview() beside applyMemberPreview (:2866-2983). No network on the canvas.

Form gates, the member edit gate, derived stamps, symbol resolution for writes, members_only gating, draft gating, row moderation. The browser clock only decides what is shown.

4. Schedules, gates, derive, upsert identity

Section titled “4. Schedules, gates, derive, upsert identity”
  • Field def: { name:'schedule', type:'schedule', options: { data_fields: [{ name:'price', type:'number' }, …], gates: ['submit','edit','show'] } }. Value: Period[], Period = { key, label, start, end, gates: { submit?: bool, edit?: bool, show?: bool }, data: Record<string, scalar> }. Ordered, non-overlapping (validated), keys unique per item.
  • CMS editor ScheduleField: add/remove/reorder periods, date-time pickers, gate toggles, data columns from data_fields, a live “current period” indicator (uses the editor clock or Preview-at). This is the whole “number of phases, deadlines, prices, via UI” requirement (OD-E).
  • Resolution: currentPeriod(schedule, now) = the period whose [start, end) contains now; null between periods. Pure, in packages/site-render/src/schedule.ts; mirrored nowhere else (runtime imports the same source string pattern as the token grammar — one implementation, parity-tested).
  • Exposed: @current.<symbol>.period (the object), .period.key, .period.gates.submit, .period.data.price, @current.<symbol>.schedule[n]. Tables and repeaters can bind a schedule as a list (a public timetable of periods).
  • Columns (migration 0040 after the master merge — master took 0039 for canvas_comments; edit packages/schema/src/schema.ts): opens_at, closes_at, closed_message, derive jsonb default [].
  • Gate order at ingest (routes/modules/forms.ts, after step a): explicit opens_at/closes_at if set; else if a derive rule of kind symbol_period exists, require period.gates.submit === true; failure → JSON 403 { ok:false, error: closed_message ?? 'Submissions are closed' } / 303 ?error=closed.
  • DeriveRule (pure, lib/form-ingest.ts):
    • { kind:'stamp_time', set:'submitted_at' }
    • { kind:'symbol', symbol:'edition', set:'edition' }
    • { kind:'symbol_period', symbol:'edition', set:'period', copy?: { price:'data.price' } } — no current period → { ok:false, error:'No open period' }.
    • { kind:'constant', set, value }. Derived keys are stripped from the client payload BEFORE validation and written AFTER coercion; they apply to creates and upsert replacements.
  • Member edit gate (routes/modules/member-data.ts PATCH): the collection may declare member_edit_gate: { symbol:'edition', match_field:'edition' } in content_types.schema; the PATCH resolves the row’s edition item (by data.<match_field>) and requires its current period gates.edit === true; else 403 { error:'Editing is closed' }. Live: an admin who moves a deadline in the schedule editor changes the verdict on the next request. Reserved keys (visitorId, submitted_at, period, edition, status, reviewState) never client-settable.
  • WriteTargetLike += upsertScope?: string[] (derived or submitted field names). Identity string = ${visitorId}|${scope values joined} → written to content_items.quota_key (already indexed unique per content type where non-null).
  • Ingest: with upsertScope, the primary write is INSERT … ON CONFLICT (content_type_id, quota_key) DO UPDATE SET data = <replacement>, review_state='new', updated_at=now(); the pre-read at forms.ts:264-309 is no longer needed for scoped upserts (kept for legacy single-field upsertKey). Replacement semantics: submitted and derived fields overwrite; fields absent from the form are kept; array fields (imageset) are replaced whole.
  • maxPerVisitor:1 + scopeField is rewritten as upsertScope:[scopeField] with mode:'create' → conflict = 409 (unchanged behaviour, one mechanism).
  • Requires a session (as today for visitorId-keyed writes).
  • No new column. An admin sets status:'draft' (pull from public) or reviewState:'archived' (hide from inbox) on the row; the member’s own list returns the row with its status so the member area can show “under review”. Admin UI: the inbox row menu gains “Unpublish entry” / “Publish entry”; the per-member items view (§6.4) has the same. The member (site_members.status) is untouched.
  • TargetSettings.symbols?: Record<string, SymbolDef>; SymbolDef = { collection, mode:'window'|'pinned', startField?, endField?, scheduleField?, itemId?, fallback?: 'latest'|'none' }. Read-merge-write (members-admin.ts:590-626 pattern), never the generic target PATCH.
  • workers/src/lib/symbols.ts: resolveSymbol(db, projectId, def, now) → { id, slug, title, data, period }|null (period from scheduleField via currentPeriod).
  • Consumers: ingest derive/gates, member PATCH gate, publish (BindingEngineOptions.symbols), published-query, member-data filters (filterValue:'@current.edition'), tables.
  • Public echo GET /api/m/:targetId/symbols → { edition: { id, slug, title, start, end, period: { key, label, start, end, gates, data } } } (allow-listed, no-store).
  • Admin UI (wave 2): CMS → Settings → Symbols: name, collection, mode, fields, schedule field; shows the resolved item and period now.

6. Rollups, tables, triggers, uploads, identity, panel, importer

Section titled “6. Rollups, tables, triggers, uploads, identity, panel, importer”
  • Field def { name, type:'rollup', options: { from, where_field, group_by?, limit? (≤100), sort? } }. FieldType union (cmsStore.ts:230-257, types/database.ts:666), field-type-meta.ts (“Related items”), FieldSchemaBuilder RollupOptionsEditor.
  • Compute: packages/site-render/src/rollup.ts (pure) + workers/src/lib/rollup-query.ts (Drizzle, project-scoped). Publish via BindingEngineOptions.resolveRollup; repeaters and tables may bind @item.<rollup>. CMS GET /api/content-items/:id/rollups (new routes/rollups.ts); member lists ?include=<rollup>.
  • Never stored, never exported as a column, never a form input, not a filter LHS in wave 1.
  • Element type table (serializer allow-list already has table/thead/tbody/tr/th/td? — verify; add if missing). Node config data_binding.data_source like a repeater + table: { columns: [{ field, header, format?: 'text'|'date'|'number'|'image'|'link'|'bool', width? }], sortable?: bool, export?: { csv: bool, xlsx: bool, filename? }, page_size? }.
  • Publish: static rows through repeater-expand.ts (same limit/ceiling rules as repeaters; data-mfn-* per row), header row from columns; A-tier paging via published-query when the collection exceeds the static ceiling (data-mf-archive pattern). Filter controls target the table like a list (targetRepeater).
  • Export: runtime module TABLE_RUNTIME_SOURCE (embedded by a hasTables trigger, same hash) builds CSV client-side from the rendered rows (or from the A-tier pages when the table is paged) and triggers a Blob download; XLSX via a minimal in-runtime writer (single sheet, strings/numbers/dates) — no external script (CSP). Cell values come from textContent/data-mfn-*, never HTML.
  • Identity columns (submitter.email, submitter.name): allowed only when the page is members_only with required_role; publish refuses otherwise (counted like onVisibilityRuleRefused); the A-tier route joins identity only for role-gated pages (session-checked, memberMeetsGate(member, requiredRole)).
  • Editor: TableProperties module (columns picker from the bound collection’s fields + rollups + submitter.*, formats, export toggles); DataSourcePicker reused. UI-lab fixture.
  • Public use: comparison/price/timetable tables (bind a collection, or a schedule). Admin use: a role-gated “Admin” page with tables of works per edition, submitter email + period + price, export → the payment sheet. In-page admin actions (publish/unpublish a row) are deferred (§11); the CMS keeps those.
  • flow-engine.ts:39-42 FlowTrigger += { on:'item_created', contentTypeId, status? }; FlowStep += InstantiateStep { type:'instantiate', template }; TemplateSpec = { forms?: [{ slug: Expr, name: Expr, fields, derive?: DeriveRule[], gateSymbol? }], pages?: [{ fromPageId, path: Expr, title: Expr }], items?: […] }; Expr = {{item.<field>}}, date offsets, literals.
  • enrollItemCreatedFlows in flow-run.ts; called from routes/content-items.ts POST and the public ingest (creates only). Idempotent (uuid5). Reuses ensureForm (provision.ts:88); clonePage in lib/page-clone.ts (verify routes/site-pages.ts for an existing duplicate helper first).
  • For 12x12 this is optional: periods live in the edition’s schedule; the works form can be one form for all editions (derive: symbol edition). Instantiation earns its place when each edition needs its own page tree.

6.4 D:submission-identity + D:row-moderation

Section titled “6.4 D:submission-identity + D:row-moderation”
  • routes/content-items.ts: include=submitter left-joins site_visitors on data->>'visitorId' (project-scoped) → submitter { visitorId, email, name }|null; filter submitter_id; row-level publish/unpublish uses the existing status PATCH.
  • members-admin.ts: GET /api/targets/:targetId/members/:memberId/items?collection= (paged).
  • UI: inbox “From” column + row menu (Unpublish/Publish entry), detail header, CSV columns submitter_email, submitter_name, submitted_at, period, price; MembersPane “Items” drawer.
  • Runtime (MEMBER_RUNTIME_SOURCE, wireUploads): multiple → sequential uploads (429 surfaced), hidden.value = JSON.stringify(urls), preview grid [data-member-upload-grid] with remove + reorder, data-member-upload-max from the field’s max_items. fillFields: IMG gets .src; [data-member-each="data.<key>"] clones per element.
  • Creator (insert-element.ts:665-698): file/image → member-upload wiring + hidden input; image_collection adds multiple + grid + max. Members library: gallery-upload, my-works-gallery; preview.html mirror.
  • components/property-editor/modules/FormProperties.tsx: Success, Spam, Identity (upsert scope / one-per-scope), Gates (explicit window or “from symbol period”), Derive (rule list with symbol pickers), Actions (summary + link to Flows). AttributesProperties.tsx:301-320 links into it. UI-lab mock-api.ts gets /api/forms fixtures.

6.7 Importer + passes (D:slots-to-array, D:folder-single-copy)

Section titled “6.7 Importer + passes (D:slots-to-array, D:folder-single-copy)”
  • data-passes.ts: PassType += 'collapse_slots_to_array' (config { sourceCollectionId, slotPattern:'image_img_{n}', targetField:'images', retireSlots }), planner, census, re-consent unchanged. TS-only (verified: no Python pass registry).
  • Dedupe rule (owner, 2026-09-13): one folder per (ref_user, edition) is valid; among duplicates keep the latest _updated_at as the row, mark the others data.duplicate_of = <kept id> and status:'draft'; census reports counts per edition. Edition of a folder = its ref_fase’s edition when present, else the year of _created_at (recorded as edition_inferred).
  • Python: form_mapper._resolve_write_destination binds to an opted-in satellite; element_mapper form-upload → file input with member-upload attrs under opt-in; member_mapper.build_member_items_sidecar carries public-row provenance ids; binding_audit.py learns satellite ids. Seed pass 2 (members-admin.ts bulk) UPDATEs data.visitorId on the public twin; inserts only rows with no twin; never flips status.
OD Question Answer
OD-A Deferred pre-filter ceiling Sensible limits apply: 500 rows deferred, static above; the intended public shape is per-edition pages / lists by edition, and admin grids are tables (D:data-tables), not deferred lists.
OD-B Anonymous uploads on public forms Keep default: members-only; file fields require sign-in.
OD-C email_flows table name Keep the table; UI says “Flows”; documented in the D:collection-triggers entry and TERMINOLOGY.
OD-D Folder single-copy One row per (artist, edition), public AND owner-editable; re-submission updates the image set for that edition (D:upsert-identity); admins can unpublish the row for review without touching the artist (D:row-moderation); the gate mechanic is universal and UI-controlled (D:periods), “phase” is not a system term.
OD-E 2026 periods/prices Not set; everything (number of periods, dates, gates, prices) is edited in the CMS schedule field of the edition item — no code, no seed.

8. Waves — disjoint file ownership, dispatch prompts

Section titled “8. Waves — disjoint file ownership, dispatch prompts”

Dispatch rules are those of plans/MEMBER-AUTH-NEXT-WORKSTREAMS.md §“Dispatch rules”: branch per stream claude/<stream-slug> from origin/master, gate pnpm ci:local --changed, builders do not touch files outside OWNS, the integrator mounts/renumbers/updates STATUS at wave end, no PR unless the prompt says so. One owner per file per wave. Shared files (workers/src/index.ts, routes/modules/index.ts, DECISIONS.md, STATUS.md, TERMINOLOGY.md) are touched by exactly the agent named, else by the integrator.

Wave 0 — foundations (5 agents, parallel)

Section titled “Wave 0 — foundations (5 agents, parallel)”
W0-A D:live-time — renderer + publish threading branch claude/live-time
GOAL. Temporal visibility rules and relative-date pre-filters are marked at publish and settled live in the browser
against /time (+ /symbols when referenced); publish pre-resolves with buildNow; A-tier accepts date ops.
READ FIRST. this plan §3, §4.1 (currentPeriod is W0-E's; import its pure fn); binding-engine.ts:545-558, :1466-1531,
:1578-1588; member-visibility.ts; collection-marker-grammar.ts:440-468 + test :239-299;
collection-filter-runtime.ts:82-109, :255-290, :2095-2145; collection-query.ts:136-145, :237-300, :445-466,
:633-648; repeater-expand.ts:635-673, :1004, :1088-1100; interactions-runtime.ts:2301-2358;
page-render.ts:118-123, :917-928, :1010-1016; publish.ts:400,448,711,900,940; lib/published-query.ts:35-42.
BUILD. A. BindingEngineOptions.now + symbols; parseRuleDate reads options.now. B. classify 'temporal'; when-marker;
MF_WHEN + whenState; grammar test owners. C. runtime prepWhen/settleWhen + boundary timer + relative-token port
+ parity test + lazy /symbols. D. partitionPreFilters + deferTemporal + data-mf-when-filter + WHEN_DEFER_MAX_ROWS
+ applyWhenFilter. E. hasTemporalMarkers trigger. F. published-query date ops, relative tokens, @current.
OWNS. packages/site-render/src/{binding-engine.ts, member-visibility.ts, collection-marker-grammar.ts,
collection-filter-runtime.ts, collection-query.ts, interactions-runtime.ts} + tests;
workers/src/lib/{repeater-expand.ts, page-render.ts, published-query.ts}; workers/src/routes/publish.ts;
workers/test/node/published-query*.test.ts.
TESTS. temporal classify; marker round-trip; settle at three clocks; boundary timer; deferred parity table (20 rows);
ceiling fallback; publish fixture flips across buildNow; CSP invariant green.
W0-B D:form-gates + D:site-symbols + D:upsert-identity + /time — Worker branch claude/form-gates
GOAL. Forms gate on a window or the current period; derive stamps period/price/submitted_at; scoped upsert is atomic
and replaces; member edits obey the period edit gate live; /time and /symbols exist.
READ FIRST. this plan §3.1, §4.2–4.4, §5; schema.ts:735-771, :140, :944-990; db/MIGRATIONS.md:131-166 (edit
packages/schema, generate 0039, review); routes/modules/forms.ts (pipeline a–i; quota :222-257; upsert pre-read
:264-309; batch :317-353); lib/form-ingest.ts; routes/modules/member-data.ts; routes/forms.ts:131-144;
members-admin.ts:574-626; routes/modules/index.ts:22-45.
BUILD. A. schema + migration 0039 (opens_at, closes_at, closed_message, derive); TargetSettings.symbols; db:check/drift.
B. lib/symbols.ts resolveSymbol (+ period via W0-E's currentPeriod). C. form-ingest DeriveRule + applyDerive +
upsertScope typing. D. ingest: gate check, derive strip/write, scoped upsert via ON CONFLICT on quota_key,
maxPerVisitor rewritten onto the same key. E. member-data PATCH edit gate + reserved keys. F. routes/modules/time.ts
(/time, /symbols) mounted in modules/index.ts. G. routes/forms.ts whitelist. H. routes/symbols-admin.ts
GET/PATCH /api/targets/:targetId/symbols (read-merge-write) + ONE mount line in index.ts.
OWNS. packages/schema/src/schema.ts; workers/drizzle/0039_* + meta; workers/src/lib/{form-ingest.ts, symbols.ts};
workers/src/routes/{forms.ts, symbols-admin.ts}; workers/src/routes/modules/{forms.ts, member-data.ts, time.ts,
index.ts}; workers/src/index.ts (one line); workers/test/node/{forms-*, member-data, symbols, time}.test.ts.
TESTS. gate open/closed/no-period; derive overwrites client value; scoped upsert replaces images and survives a
concurrent duplicate (unique index → update, not 500); edit gate flips when the schedule changes; /time
no-store; /symbols allow-list.
W0-C D:submission-identity + D:row-moderation — admin branch claude/submission-identity
OWNS. workers/src/routes/{content-items.ts, members-admin.ts}; frontend/src/components/cms/{SubmissionInboxView.tsx,
SubmissionDetail.tsx, MembersPane.tsx}; frontend/src/lib/cms/submission-csv.ts; frontend/src/lib/api/{content.ts,
members.ts}; frontend/src/pages/ui-lab/mock-api.ts; tests beside each.
BUILD. §6.4. TESTS. join null for anonymous rows; cross-project never joins; per-member items paged; CSV columns;
unpublish row leaves site_members untouched.
W0-D docs skeleton + terminology branch claude/recurring-docs-0
OWNS. docs/architecture/TERMINOLOGY.md (edition/container, schedule, period, gate, symbol, derive rule, rollup,
temporal rule, data table, upsert scope, instantiation, participation); apps/docs-product/src/content/docs/**
stubs ("coming"): publish/live-time.md, cms/schedules.md, cms/rollups.md, cms/tables.md, cms/editions.md;
scripts/sync-content.mjs manifest entry for this plan.
W0-E D:periods — schedule field type + editor branch claude/schedule-field
GOAL. A non-technical admin edits periods (count, dates, gates, prices) on a container item in the CMS; the pure
resolver is shared by Worker, renderer and runtime.
READ FIRST. this plan §4.1; cmsStore.ts:230-290 (FieldType, FieldOptions); field-type-meta.ts; FieldSchemaBuilder.tsx
(:907-942 MediaOptionsEditor as the options-editor pattern); fields/FieldEditor.tsx, CellRenderer.tsx;
lib/cms/image-value.ts (value-normalizer pattern); D:member-preview for the editor clock.
BUILD. A. packages/site-render/src/schedule.ts: types, validate (ordered, non-overlapping, unique keys),
currentPeriod(schedule, now), nextBoundary(schedule, now). B. FieldType 'schedule' + options editor
(data_fields, gates). C. ScheduleField editor (rows, pickers, gate toggles, data columns, "current now" chip).
D. CellRenderer summary (n periods, current). E. server-side validation hook exported for W0-B (pure).
OWNS. packages/site-render/src/{schedule.ts, schedule.test.ts}; frontend/src/stores/cmsStore.ts;
frontend/src/types/database.ts; frontend/src/lib/cms/field-type-meta.ts;
frontend/src/components/cms/schema/FieldSchemaBuilder.tsx; frontend/src/components/cms/fields/{ScheduleField.tsx
(new), FieldEditor.tsx, CellRenderer.tsx}; frontend/src/lib/cms/schedule-value.ts (new); tests beside each.
TESTS. resolver at boundaries (inclusive start, exclusive end, gap → null); validation errors; editor add/remove/
reorder; ui-lab fixture with three periods.

Integrator (wave 0 end): merge E → A → B → C → D (A and B import E’s pure module); DECISIONS entries for D:live-time, D:periods, D:form-gates, D:site-symbols, D:upsert-identity, D:submission-identity, D:row-moderation; STATUS in-flight; pnpm ci:local; PR per branch, base master.

Wave 1 — features (7 agents, parallel, after wave 0 merges)

Section titled “Wave 1 — features (7 agents, parallel, after wave 0 merges)”

Wave-0 integration left three small seams for the wave-1 integrator (reported by W0-I, none blocking): workers/src/lib/archive-compile.ts + archive-publish.ts (B+ archive tier) and workers/src/routes/export.ts:107 (PDF/booklet export) do not yet receive symbols, so a @current.* temporal rule fails closed there; and the equals/not_equals operators do not resolve @current.* operands (only the date operators do) — W1-A owns binding-engine.ts in wave 1 and adds operand resolution to the scalar branch.

W1-A D:rollup-fields branch claude/rollup-fields
OWNS. packages/site-render/src/{rollup.ts (new), binding-engine.ts, rollup.test.ts}; workers/src/lib/rollup-query.ts;
workers/src/routes/rollups.ts + ONE mount line in workers/src/index.ts; workers/src/lib/page-render.ts
(resolveRollup feed only); frontend/src/stores/cmsStore.ts; frontend/src/types/database.ts;
frontend/src/lib/cms/field-type-meta.ts; frontend/src/components/cms/schema/FieldSchemaBuilder.tsx;
frontend/src/components/cms/fields/{FieldEditor.tsx, CellRenderer.tsx}; frontend/src/types/data-binding.ts;
frontend/src/components/property-editor/modules/data-binding/**.
BUILD. §6.1. TESTS. pointer identity + array-contains; group_by; limit; repeater bound to @item.<rollup>; CMS route
project scoping; member include.
W1-B D:imageset-upload branch claude/imageset-upload
OWNS. packages/site-render/src/interactions-runtime.ts (MEMBER_RUNTIME_SOURCE section ONLY);
frontend/public/preview.html; frontend/src/lib/component-libraries/libraries/members.ts + verify test;
frontend/src/lib/operations/insert-element.ts; frontend/src/lib/forms/binding-composition.ts;
workers/src/routes/modules/member-uploads.ts; workers/test/node/member-uploads.test.ts;
packages/site-render/src/form-render.test.ts.
BUILD. §6.5. TESTS. multi upload → JSON array; 429 surfaced; grid remove/reorder; img.src fill; each-clone; creator
wiring; imageset round-trip + replacement on re-submit (with W0-B's upsertScope).
W1-C D:form-settings-panel branch claude/form-settings-panel
OWNS. frontend/src/components/property-editor/modules/FormProperties.tsx (new) + test; AttributesProperties.tsx
(:301-320 hand-off only); PropertiesPanel.tsx (registration); frontend/src/lib/api/forms.ts;
frontend/src/lib/api/symbols.ts (new, read-only picker); frontend/src/pages/ui-lab/{mock-api.ts, fixtures.ts}.
BUILD. §6.6. TESTS. every section from a fixture; PATCH body shape incl. upsertScope/derive/gates; ui-lab screenshot.
W1-D D:data-tables branch claude/data-tables
OWNS. packages/site-render/src/{table-render.ts (new), table-runtime.ts (new: TABLE_RUNTIME_SOURCE, CSV + minimal
XLSX writer), html-serializer.ts (table allow-list + honeypot-style emit), element-capabilities.ts,
element-meta.ts, collection-marker-grammar.ts (MF_TABLE namespace, declared in the sweep),
interactions-runtime.ts (ONE trigger line: hasTables)}; workers/src/lib/repeater-expand.ts (table rows branch);
workers/src/routes/published-query.ts (identity join for role-gated pages);
frontend/src/components/property-editor/modules/TableProperties.tsx (new); frontend/src/lib/operations/
insert-element.ts (table insert — coordinate: W1-B owns the creator's file in this wave → W1-D's table insert
lands in a NEW file frontend/src/lib/operations/insert-table.ts and a one-line dispatch added by the integrator);
frontend/src/pages/ui-lab fixtures for the table.
BUILD. §6.2. TESTS. columns/format render; export CSV equals rendered rows; XLSX opens (parse back in test); identity
columns refused on a public page and allowed on members_only+role; A-tier paging; CSP invariant.
W1-E D:member-invite-send branch claude/member-invite-send
Dispatch plans/MEMBER-AUTH-NEXT-WORKSTREAMS.md WS-1 verbatim (rebase on merged master first — W0-C touched
members-admin.ts and MembersPane.tsx).
W1-F D:slots-to-array + D:folder-single-copy branch claude/slots-and-folders
OWNS. workers/src/lib/data-passes.ts; workers/src/routes/passes.ts; workers/src/routes/members-admin.ts (bulk seed
handler ONLY; integrator merges W1-E first); workers/test/node/{data-passes, members-bulk-seed.pglite}.test.ts;
apps/bondlayer-worker/src/{form_mapper.py, element_mapper.py (form-upload only), member_mapper.py,
import_bondlayer.py, binding_audit.py, xlsx_cms.py} + their test_*.py.
BUILD. §6.7 incl. the one-per-(owner, edition) dedupe with `duplicate_of`. TESTS. 12-slot fixture census/plan;
retired markers; re-consent; dedupe keeps latest; seed pass 2 updates the public twin; form_mapper binds an
opted-in satellite; upload element → file input; audit no false-warn.
W1-G D:site-symbols admin UI + Preview-at branch claude/symbols-ui
OWNS. frontend/src/components/cms/SymbolsPane.tsx (new) + route in frontend/src/pages/cms/CMSPage.tsx;
frontend/src/stores/editorStore.ts (previewAt); the preview-as menu component (locate: grep previewAs);
frontend/src/lib/api/symbols.ts is W1-C's — W1-G consumes it after W1-C merges, or stubs locally and the
integrator dedupes.

Integrator (wave 1 end): merge E → F, then A → D → B → C → G; the one-line table-insert dispatch in insert-element.ts; DECISIONS entries for the seven slugs; STATUS; pnpm ci:local.

Wave 2 — application, triggers, docs, verification (4 agents)

Section titled “Wave 2 — application, triggers, docs, verification (4 agents)”
W2-A D:collection-triggers + Flows UI branch claude/collection-triggers
OWNS. workers/src/lib/{flow-engine.ts, flow-run.ts, instantiate.ts (new), page-clone.ts (new)};
workers/src/routes/{flows.ts, content-items.ts (POST hook only), site-pages.ts (if a duplicate helper is
extracted)}; workers/src/routes/modules/forms.ts (ONE call after the batch); workers/src/capabilities/provision.ts
(export only); Flows panel components (trigger + instantiate editors); workers/test/node/{flow-*, instantiate}.
BUILD. §6.3. TESTS. item_created enrolls; instantiate idempotent; template Expr dates; ensureForm reuse; page clone.
W2-B 12x12 end-to-end fixture + runbook branch claude/12x12-recurring
OWNS. workers/test/node/recurring-12x12.pglite.test.ts (new): edition with a 3-period schedule → symbol resolves →
submit in period 1 (period/price stamped) → re-submit replaces images → PATCH allowed/refused as the schedule
is edited → admin unpublishes the row, member still sees it → rollup users.participations → role-gated table
export contains submitter email + period; plans/OWNER-OPS.md (§9 runbook entry);
apps/bondlayer-worker/tools/12x12_recurring_setup.py (emits pass + symbol payloads from the capture; counts
and field names only, no PII).
W2-C docs branch claude/recurring-docs
OWNS. apps/docs-product/src/content/docs/{publish/forms.md, publish/members.md, publish/live-time.md,
cms/schedules.md, cms/rollups.md, cms/tables.md, cms/editions.md}; ARCHITECTURE.md (live-time tier, symbols,
schedules); docs/architecture/OVERVIEW.md; STATUS.md; this plan's status header (as-built); DECISIONS.md
`D:recurring-structures` umbrella entry.
W2-D Playwright verification branch claude/recurring-verify
OWNS. frontend/e2e/** (or the repo's Playwright dir): UI-lab screenshots of ScheduleField, FormProperties,
TableProperties, SymbolsPane; a published fixture under `wrangler dev` with /time stubbed at three instants —
the period block flips, the deferred list hides/shows, the table exports. Reports only.

9. 12x12 application (the runbook W2-B writes into OWNER-OPS)

Section titled “9. 12x12 application (the runbook W2-B writes into OWNER-OPS)”
  1. Prerequisites already queued in OWNER-OPS: Render rebuild, re-import with “Import members” ticked, member-settings {enabled:true, joinMode:'invite'}, MEMBER_SESSION_SIGNING_KEY set.
  2. Collections: editions ← “1.0 Edições” (exists) + a schedule field (periods replace “0.4 Fases 2024”, whose 2024 rows are migrated into the 2024 edition’s schedule by the setup tool); works ← users_folders after collapse_slots_to_array (images, ref_user, edition, period), deduped one per (artist, edition).
  3. Symbol: edition = { collection: editions, mode:'window', startField:'datetime_data_inicio', endField:'datetime_data_de_fim', scheduleField:'schedule', fallback:'latest' }.
  4. One works form: derive: [symbol edition, symbol_period period + price, stamp_time], upsertScope:['edition'], gate = @current.edition.period.gates.submit; works collection member_edit_gate: { symbol:'edition', match_field:'edition' }.
  5. Pages: members area = account-page + gallery-upload + my-works-gallery (rollup-fed, shows row status); public = per-edition detail pages + a list filtered by edition = @current.edition; period copy blocks gated by data-mf-when on period.key.
  6. Admin page (members_only, required_role:'admin'): a table of works for @current.edition with submitter.email, submitter.name, period, price, status; export XLSX = the payment sheet. Row unpublish/publish stays in the CMS inbox.
  7. 2026: the admin adds the edition item, fills its schedule (as many periods as wanted, dates, gates, prices). Nothing else.
  8. Retire the wide users fields (boolean_fase_*, ref_folder_*, boolean_inscrito_*) via a follow-up pass once rollups are verified.

10. Gates and invariants (unchanged, all CI-gated)

Section titled “10. Gates and invariants (unchanged, all CI-gated)”

published-csp-invariant.test.ts (script-src forever 'none'|hashes); collection-marker-grammar.test.ts orphan sweep; decision-number-guard.test.ts; db-guard (drift); sandbox-gate.test.ts; members-library.verify.test.ts; pnpm ci:local --changed before every push.

11. Deferred with reasons (do not build here)

Section titled “11. Deferred with reasons (do not build here)”
  • Anonymous uploads on public forms (OD-B): needs a signed anonymous grant + abuse controls; member-gated covers 12x12.
  • In-page admin actions on published tables (publish/unpublish a row from the admin grid): needs a role-scoped write route and audit; the CMS inbox does it today.
  • Multi-hop rollups and rollups as filter LHS: one hop matches the reference-hop doctrine (D218).
  • Overlapping periods / multiple concurrent schedules per item: one ordered, non-overlapping schedule per field; a second need gets a second schedule field.
  • Schema mutation on item created (the original “schema linker”): rejected — see §0.
  • Per-member static rendering / owner-rule evaluation at publish (G12): S16 doctrine stands.
  • Turnstile on published pages (D:turnstile-published) and the contact-identity join (G11): separate owner decisions on the ledger.