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 (perSTATUS.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 inplans/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 newformscolumns, one newtargets.settingskey, 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.
0. Why (what the audit found)
Section titled “0. Why (what the audit found)”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):
- 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.
- Symbols — a named, dated “current” pointer (
@current.edition,@current.edition.period) that forms, filters, visibility and derive rules reference. - Rollups — read-only reverse references computed at query time.
- Collection triggers + instantiation — “item created in X” spawns forms and pages from a template (periods no longer need it; forms/pages still do).
- 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 periodjoin works one row per participant per edition: visitorId, edition, period, images[], …identity upsertScope ['edition'] re-submitting replaces the row for this member + editiongates period.gates.submit/edit/show what the form, the member PATCH and the temporal runtime obeyderive period, price, submitted_at stamped at ingest from the current period; the billing recordrollups users.participations works where ref_user = @item, grouped by editiontables admin grid on a role-gated page: works of this edition + submitter email + period; export CSV/XLSXlive time public pages flip at the period boundary; lists filtered by @current.edition; per-edition detail pagesUse 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”3.1 Clock
Section titled “3.1 Clock”- New public module route
GET /api/m/:targetId/time→{ "now": <epoch ms> },Cache-Control: no-store, KV rate-limited (60/min/IP likepublished-query.ts:147). Mounted inroutes/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')readsoptions.now ?? Date.now(). Publish threadsbuildNow(publish.ts:400) throughpage-render.ts. The “no clock injected” corner (binding-engine.ts:545-551) closes.
3.2 Temporal visibility rules
Section titled “3.2 Temporal visibility rules”classifyVisibilityRule(member-visibility.ts) gains a fourth kindtemporal: operator ∈ {date_eq,before,after,between}, or operand@now/ relative token /@current.<symbol>.period….- Publish (
resolveNodeBindingsconditionals block,binding-engine.ts:1466-1531): temporal rules →{ kind:'when-marker', encoded, hiddenDefault }withhiddenDefault= evaluation atbuildNow. Marker write beside the audience case (:1578-1588). - Grammar: sibling namespace
MF_WHEN = { rules: 'data-mf-when' }(collection-marker-grammar.ts, patternMF_AUDIENCE:458-466);MF_RUNTIME_WRITTEN.whenState = 'data-mf-when-state'(pending | live). Declared in the orphan sweep; never inIMPORTER_EMITTED_MARKERS. - Runtime (
collection-filter-runtime.ts, besideprepAudienceEls/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-whenpayload may reference@current.edition.period.gates.showor.key == 'late'; the runtime resolves it from/symbols(lazy, only when referenced). - Embed trigger:
interactions-runtime.ts:2310predicate +=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.*, ordate_*over either).applyCollectionQueryapplies onlystaticwhenoptions.deferTemporal.repeater-expand.ts(:1004): emit survivors (all items already carrydata-mfn-<field>,:635-673), stamp the list rootdata-mf-when-filter+data-mf-when-limit. CeilingWHEN_DEFER_MAX_ROWS = 500; above it, the list publishes static (build clock) with adev-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 fromresolveDateValuewith a parity test; divergence #3 (:277-279) deleted. - A-tier:
lib/published-query.ts:35-42acceptsdate_*,between-over-dates, relative tokens and@current.*(server-resolved).created_at/updated_atfilterable 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.
3.4 Canvas
Section titled “3.4 Canvas”- “Preview as” (D222) gains “Preview at” (date-time) →
editorStore.previewAt→ enginenowandpreview.htmlmirrorapplyWhenPreview()besideapplyMemberPreview(:2866-2983). No network on the canvas.
3.5 What stays server-authoritative
Section titled “3.5 What stays server-authoritative”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”4.1 schedule field type (D:periods)
Section titled “4.1 schedule field type (D:periods)”- 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 fromdata_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;nullbetween periods. Pure, inpackages/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).
4.2 Form gates + derive (D:form-gates)
Section titled “4.2 Form gates + derive (D:form-gates)”- Columns (migration
0040after the master merge — master took0039forcanvas_comments; editpackages/schema/src/schema.ts):opens_at,closes_at,closed_message,derive jsonb default []. - Gate order at ingest (
routes/modules/forms.ts, after step a): explicitopens_at/closes_atif set; else if a derive rule of kindsymbol_periodexists, requireperiod.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.tsPATCH): the collection may declaremember_edit_gate: { symbol:'edition', match_field:'edition' }incontent_types.schema; the PATCH resolves the row’s edition item (bydata.<match_field>) and requires its current periodgates.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.
4.3 Upsert identity (D:upsert-identity)
Section titled “4.3 Upsert identity (D:upsert-identity)”WriteTargetLike+=upsertScope?: string[](derived or submitted field names). Identity string =${visitorId}|${scope values joined}→ written tocontent_items.quota_key(already indexed unique per content type where non-null).- Ingest: with
upsertScope, the primary write isINSERT … ON CONFLICT (content_type_id, quota_key) DO UPDATE SET data = <replacement>, review_state='new', updated_at=now(); the pre-read atforms.ts:264-309is no longer needed for scoped upserts (kept for legacy single-fieldupsertKey). Replacement semantics: submitted and derived fields overwrite; fields absent from the form are kept; array fields (imageset) are replaced whole. maxPerVisitor:1+scopeFieldis rewritten asupsertScope:[scopeField]withmode:'create'→ conflict = 409 (unchanged behaviour, one mechanism).- Requires a session (as today for visitorId-keyed writes).
4.4 Row moderation (D:row-moderation)
Section titled “4.4 Row moderation (D:row-moderation)”- No new column. An admin sets
status:'draft'(pull from public) orreviewState:'archived'(hide from inbox) on the row; the member’s own list returns the row with itsstatusso 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.
5. Site symbols (D:site-symbols)
Section titled “5. Site symbols (D:site-symbols)”TargetSettings.symbols?: Record<string, SymbolDef>;SymbolDef = { collection, mode:'window'|'pinned', startField?, endField?, scheduleField?, itemId?, fallback?: 'latest'|'none' }. Read-merge-write (members-admin.ts:590-626pattern), never the generic target PATCH.workers/src/lib/symbols.ts:resolveSymbol(db, projectId, def, now) → { id, slug, title, data, period }|null(period fromscheduleFieldviacurrentPeriod).- Consumers: ingest derive/gates, member PATCH gate, publish (
BindingEngineOptions.symbols),published-query,member-datafilters (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”6.1 D:rollup-fields
Section titled “6.1 D:rollup-fields”- Field def
{ name, type:'rollup', options: { from, where_field, group_by?, limit? (≤100), sort? } }.FieldTypeunion (cmsStore.ts:230-257,types/database.ts:666),field-type-meta.ts(“Related items”),FieldSchemaBuilderRollupOptionsEditor. - Compute:
packages/site-render/src/rollup.ts(pure) +workers/src/lib/rollup-query.ts(Drizzle, project-scoped). Publish viaBindingEngineOptions.resolveRollup; repeaters and tables may bind@item.<rollup>. CMSGET /api/content-items/:id/rollups(newroutes/rollups.ts); member lists?include=<rollup>. - Never stored, never exported as a column, never a form input, not a filter LHS in wave 1.
6.2 D:data-tables (G13)
Section titled “6.2 D:data-tables (G13)”- Element type
table(serializer allow-list already hastable/thead/tbody/tr/th/td? — verify; add if missing). Node configdata_binding.data_sourcelike 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 fromcolumns; A-tier paging viapublished-querywhen the collection exceeds the static ceiling (data-mf-archivepattern). Filter controls target the table like a list (targetRepeater). - Export: runtime module
TABLE_RUNTIME_SOURCE(embedded by ahasTablestrigger, 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 fromtextContent/data-mfn-*, never HTML. - Identity columns (
submitter.email,submitter.name): allowed only when the page ismembers_onlywithrequired_role; publish refuses otherwise (counted likeonVisibilityRuleRefused); the A-tier route joins identity only for role-gated pages (session-checked,memberMeetsGate(member, requiredRole)). - Editor:
TablePropertiesmodule (columns picker from the bound collection’s fields + rollups +submitter.*, formats, export toggles);DataSourcePickerreused. 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.
6.3 D:collection-triggers (wave 2)
Section titled “6.3 D:collection-triggers (wave 2)”flow-engine.ts:39-42FlowTrigger+={ 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.enrollItemCreatedFlowsinflow-run.ts; called fromroutes/content-items.tsPOST and the public ingest (creates only). Idempotent (uuid5). ReusesensureForm(provision.ts:88);clonePageinlib/page-clone.ts(verifyroutes/site-pages.tsfor 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=submitterleft-joinssite_visitorsondata->>'visitorId'(project-scoped) →submitter { visitorId, email, name }|null; filtersubmitter_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.
6.5 D:imageset-upload
Section titled “6.5 D:imageset-upload”- 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-maxfrom the field’smax_items.fillFields:IMGgets.src;[data-member-each="data.<key>"]clones per element. - Creator (
insert-element.ts:665-698):file/image→ member-upload wiring + hidden input;image_collectionaddsmultiple+ grid + max. Members library:gallery-upload,my-works-gallery;preview.htmlmirror.
6.6 D:form-settings-panel
Section titled “6.6 D:form-settings-panel”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-320links into it. UI-labmock-api.tsgets/api/formsfixtures.
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_atas the row, mark the othersdata.duplicate_of = <kept id>andstatus:'draft'; census reports counts per edition. Edition of a folder = itsref_fase’s edition when present, else the year of_created_at(recorded asedition_inferred). - Python:
form_mapper._resolve_write_destinationbinds to an opted-in satellite;element_mapperform-upload→ file input with member-upload attrs under opt-in;member_mapper.build_member_items_sidecarcarries public-row provenance ids;binding_audit.pylearns satellite ids. Seed pass 2 (members-admin.tsbulk) UPDATEsdata.visitorIdon the public twin; inserts only rows with no twin; never flips status.
7. Owner decisions (answered 2026-09-13)
Section titled “7. Owner decisions (answered 2026-09-13)”| 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-timeGOAL. 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-gatesGOAL. 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-identityOWNS. 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-0OWNS. 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-fieldGOAL. 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-fieldsOWNS. 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-uploadOWNS. 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-panelOWNS. 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-tablesOWNS. 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-sendDispatch plans/MEMBER-AUTH-NEXT-WORKSTREAMS.md WS-1 verbatim (rebase on merged master first — W0-C touchedmembers-admin.ts and MembersPane.tsx).W1-F D:slots-to-array + D:folder-single-copy branch claude/slots-and-foldersOWNS. 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-uiOWNS. 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-triggersOWNS. 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-recurringOWNS. 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-docsOWNS. 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-verifyOWNS. 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)”- Prerequisites already queued in OWNER-OPS: Render rebuild, re-import with “Import members” ticked,
member-settings {enabled:true, joinMode:'invite'},MEMBER_SESSION_SIGNING_KEYset. - Collections:
editions← “1.0 Edições” (exists) + aschedulefield (periods replace “0.4 Fases 2024”, whose 2024 rows are migrated into the 2024 edition’s schedule by the setup tool);works←users_foldersaftercollapse_slots_to_array(images,ref_user,edition,period), deduped one per (artist, edition). - Symbol:
edition = { collection: editions, mode:'window', startField:'datetime_data_inicio', endField:'datetime_data_de_fim', scheduleField:'schedule', fallback:'latest' }. - One works form:
derive: [symbol edition, symbol_period period + price, stamp_time],upsertScope:['edition'], gate =@current.edition.period.gates.submit;workscollectionmember_edit_gate: { symbol:'edition', match_field:'edition' }. - Pages: members area =
account-page+gallery-upload+my-works-gallery(rollup-fed, shows row status); public = per-edition detail pages + a list filtered byedition = @current.edition; period copy blocks gated bydata-mf-whenonperiod.key. - Admin page (
members_only,required_role:'admin'): atableofworksfor@current.editionwithsubmitter.email,submitter.name,period,price,status; export XLSX = the payment sheet. Row unpublish/publish stays in the CMS inbox. - 2026: the admin adds the edition item, fills its schedule (as many periods as wanted, dates, gates, prices). Nothing else.
- Retire the wide
usersfields (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
schedulefield. - 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.