Repository navigation
test(qa): the 18.x line's upgrade harness (an 18.0 database boots on every later main with its data) and the measured step plan #24522
Description
Filing gate ③: a task the maintainer directed. In the epic seat's chat the maintainer wrote 「我不会创建分支,所以你后续的开发能不能一条线一条线的完成,每个开发合并之后都可以随时发版本?如果这样的话要怎么安排」, and on the seat's plan 「同意,按照这个原则继续」. The ruling record is on #15194, posted next.
- Reader who acts: the epic seat (
epic:#15194,session_01Y5583bs2RDd53MH6kM1ca3), which dispatches it now. - Dedupe: none. No open card carries an 18.0→HEAD upgrade check.
Part of #15194 (ADR-0131). Step 0 of the 18.x line. The parents #15212, #24343 and #24421 wait on this card. ⛔ Not an 18.0 gate.
The line's rule (what this card makes measurable)
Every PR on the 18.x line, once merged, leaves main releasable:
- R1. A fresh install boots and serves.
- R2. A database an 18.0 install created upgrades to that commit, boots, and keeps its data.
- R3. A v17 database is still refused (stay on 17.x), until the upgrade ceremony opens at step 6.
- R4. A walled posture is still refused at boot, until the walled lift at step 4.
Intermediate steps only add: a new write shape, plus reads that accept both shapes. Every move or delete of 18.0 data happens only inside the upgrade ceremony (step 5). Its command keeps refusing until step 6.
Wanted
A. The upgrade harness (R2), built and green on main.
-
The fixture. Produce an 18.0-shaped, populated database: the 18.0 code, a showcase boot, then real use. It must touch every table steps 1–7 will reshape:
- sign-up and invitation (identity mail);
- a setting change and its audit;
- a secret;
- a declared webhook (seeded rows);
- a flow credential;
- share links, activity and grants.
Commit it as a regenerable, reviewable fixture, plus the script that regenerates it from a named git ref. Use the 18.0 release tag once it exists; until then,
mainat or afterf8ef14487(feat(objectql)!: undersingle, a platform object's organization-less system insert is stamped or refused (C8 stage 8b) #24513). -
The test. Boot HEAD on a copy of the fixture. Assert:
- the boot succeeds with no ownership refusal;
- row counts and key rows of the reshaped tables are intact;
- the v17 refusal is unchanged, against a v17-shaped fixture or the existing boot-guard pins.
-
Where it runs. The line's PRs must be able to run it before merge. Today dogfood is skipped on
pull_requestand in the merge group. Measure the cheapest wiring (an opt-in label lane or similar) and build it. If that wiring needs a governed path, put it in its own small PR for the maintainer.
B. The measured plan for steps 1–7 (report only, no code). For each step, list:
- every table or object it reshapes;
- the expand form: the new write shape and the dual read;
- what the step-5 ceremony must later move or delete;
- what boot-time schema sync does today on an 18.0 database, both for the additive part and for a removal;
- the PR-by-PR cut, with each PR's file surface, size, and how it proves R1–R4.
The steps, from the ruling:
- 8b-0g′, declared webhooks from the registry;
- 8b-0e′:
sys_secret, the settings-audit sibling, and the deployment-levelsys_flow_credential; - 8b-0d′, the identity-mail sibling;
- the walled lift: 8b's walled half plus the chore(cli)!: 18.x restore of the 18.0 guards — re-open the upgrade ceremony and walled boots #24489 restore, in one landing;
- the ceremony steps behind the refusal: moves and deletes, 9c (plugin-sharing: refuse a new or renamed sys_sharing_rule row that takes a declared rule's name, and reconcile the shadowed declaration's grants on a same-name write (ruling B′ on #24378) #24421), and 8e
NOT NULL; - opening the ceremony;
- 8c and 8d, plus removing the dual reads.
The seat files one child card per step from this report.
Acceptance
- The harness runs green on
mainand goes red when an 18.0 table loses data or the boot is refused. Ablate it once to prove that. - The report carries the step plan above.
Clause-②: no: tests, a fixture and scripts only.
Activity
objectstack-fleet commentedon Oct 19, 2026
Claim: PM loop round 10 · 2026-10-19T12:03Z
Session:
session_01Y5583bs2RDd53MH6kM1ca3Account:
os-steve(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-24522-18x-upgrade-harness. If the CI wiring needs a governed path, it goes on a second branch,claude/issue-24522-harness-lane, as its own small PR for the maintainer.Worktree: the cloud session's own checkout (
mode:cloud)Domain:
domain:engineSeat:
epic:#15194(the epic lane)File surface, read on
origin/mainf8ef14487. Stop on a breach and explain it in the report.packages/qa/dogfood/**: the harness test and its fixture.scripts/: the fixture generator only.content/docs/**. ⛔ Notdocs/adr/**.Container & model:
M,mode:cloud,model: TIER_DEFAULTClause-②: no — tests, a fixture and scripts only
Responsibility: n/a — a ruled scope stage (ruling record 6266750393 on [epic] ADR-0131 — total organization ownership: no NULL organization_id (v18 line) #15194)
Thread-read: 6266750393
Serial constraints cleared:
content/docs,cliandruntimestrings, notpackages/qa/dogfood/**.objectstack-fleet commentedon Oct 19, 2026
os-dev-report
{
"issue": 24522,
"status": "done",
"branch": "claude/issue-24522-18x-upgrade-harness",
"pr": "#24552",
"pr_lane": "#24554 (claude/issue-24522-harness-lane, GOVERNED Tier H: the maintainer's merge)",
"session": "session_01SH2ojNSqz93pYiNSmUyQnf",
"premise_still_valid": true,
"summary": "A (the harness): an 18.0 showcase database after real use (sign-ups, invitation, password reset, settings incl. an encrypted one, a flow credential, the declared webhook customized and delivering, a runtime sharing rule, a share link, record activity), generated from 5b14b7f (main, contains f8ef144) and committed as SQL text (920 lines, 431 KB) plus a manifest of every row of the 14 reshaped tables; a regenerator
node scripts/gen-upgrade-harness-fixture.mjs --ref REF [--checkout DIR]; andpackages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts, which boots HEAD (ownership guard composed) on a copy and asserts no refusal, the fixture's people sign in and its task is served, every reshaped row keeps its key columns, and a v17-trait copy is still refused (R3). Green 5/5, ablated red twice and restored. Wiring: an opt-in label lane (needs:upgrade-harness, the pack-smoke-optin shape) on the second draft PR #24554, governed. B: the measured plan for steps 1-7 is under step_plan; its headline: declaring sys_secret / sys_flow_credential column-less before step 6 refuses every 18.0 database at boot (the guard reads the physical column), so that flip must land with the open ceremony; 8e NOT NULL, step 6's 18.0 verdict and 8d's index redefinition each need a decision first.","tests": "HEAD f6128e3:
pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/upgrade-18x-harness.dogfood.test.tsunder os-verify-lock:Test Files 1 passed (1)·Tests 5 passed (5)·Duration 53.89s (import 42.80s, tests 10.70s);pnpm --filter @objectstack/dogfood typecheckVERDICT command-exit 0; eslint --no-inline-config on the 6 new files exit 0. Ablation (fixture SQL is a data file, no build/dist leg), via scripts/ablation-replace.mjs: leg 0 was a NO-OP (the replacement contained the anchor; the tool refused,anchor count moved 1 -> 1, and restored; nothing measured). Leg A: sys_webhook INSERT commented out (anchor 1 -> 0, blob e43efe830f55 -> 5a8d318e7b45):x 3. every reshaped table keeps every 18.0 row,AssertionError: sys_webhook row whk_8a24917e-...: expected undefined to deeply equal {...(4)},Tests 1 failed | 4 passed (5)(the boot seeder re-created a row, so only the by-id check caught it). Leg B: sys_metadata created with a physical organization_id (blob -> a5b65a723f9b): x1 (guard refusal,This database is still in the v17 organization-ownership shape: sys_metadata ...), x2, x4,Tests 3 failed | 2 passed (5). Each restore proven by the tool: blob == HEAD e43efe830f55,git diff HEADempty. Narrowed: only this file of the dogfood suite ran locally (a new file, no shared fixture touched); the whole suite runs in main's full battery.","gates": "Harness branch (HEAD f6128e3):
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackgave 73 commands; 71 exit 0; 2 NOT MEASURED, exit 3 prerequisite:node scripts/check-plugin-teardown-shape.mjs --self-test(positive control at 621a487 unreachable in this shallow clone) andpnpm check:dual-build-cjs-loads(no dist for 8 packages outside the dogfood closure);--ranwith exit codes:73 derived famil(ies) accounted for, 71 run, 2 NOT-MEASURED.check-governed-merges --branch: NOT governed, 2769 lines (+2769/-0), under 3000. Lane branch (HEAD c7101e9): 52 derived, all exit 0 (pnpm check:pm-dispatch-gatesre-run alone: 1913 fast-tier cases pass in 465.4s, after my first wrapper cut it at 300s);node scripts/pm/check-expected-skips.mjs --self-test154 cases pass;--ran: 52 accounted, 0 NOT-MEASURED;check-governed-merges --branch: GOVERNED, tier H, 136 lines. CI check-runs on #24552/#24554: in_progress at report time (not waited for, per contract).","mcp_calls": "0",
"api_writes": "2 relay strokes: fw-20261019T133225Z-6d898d, pr_create x2 (POST /repos/objectstack-ai/objectstack/pulls x2, with assignees + labels legs; read-back identical, 6852 and 4270 bytes); and this report comment (POST /repos//issues/24522/comments). Plus git pushes (not REST). Reads via gh api (GET only).",
"open_questions": [
{
"question": "Wiring: the lane runs only where the label is hung; it is advisory, not a required context (merge_group carries no PR, so a label cannot be read there). Is that enough for the ruling's 'R2 is a new required check for this line's PRs'? Decided A for this PR.",
"options": [
"A: label lane as built; the seat hangs
needs:upgrade-harnesson every line PR and reads the check before queueing","B: also add the harness to a required context (an always-on job filtered to the line's paths); taxes more PRs and touches ci.yml"
],
"recommendation": "A, by the axes: (4) startup focus, one file only on the line's PRs; (2) the line is serial at concurrency 2 or less, so a seat-read check is reliable. B only if a line PR ever lands without the label."
},
{
"question": "Fixture ref: generated from main 5b14b7f (contains f8ef144) because no 18.0 release tag exists. Regenerate from the tag once it is cut? Decided A as the follow-up, not done here.",
"options": [
"A: regenerate from the 18.0 tag the day it exists (one commit:
node scripts/gen-upgrade-harness-fixture.mjs --ref TAG)","B: keep the main-sha fixture"
],
"recommendation": "A: the card names the tag as the preferred source; the diff between 5b14b7f and the tag is release plumbing, so the regeneration should be coverage-identical."
},
{
"question": "Step 6: how is an 18.0 database recognised once the ceremony opens (step_plan.steps[5].decision_needed)? Needs the maintainer.",
"options": [
"A: a birth marker the guard writes on a datastore created from empty; every marker-less 18.0 database refused until the ceremony (amends the migration.zod.ts:181-185 prohibition; a ruling)",
"B: shape probes added to findV17ShapeTables (partial by construction)"
],
"recommendation": "A, axis (1) long-term soundness (one structural rule, no probe list to keep complete) and (3) AI error-proofing (a probe list is exactly what a later step forgets to extend). It needs the maintainer because it amends a recorded prohibition and redefines R2 at step 6."
},
{
"question": "Step 5 / 8e: how is a per-table NOT NULL on organization_id expressed so boot-time drift does not read it as relax_not_null? Needs an engine-lane measurement, then a ruling.",
"options": [
"A: the differ treats the ceremony as owner of organization_id nullability (no metadata change)",
"B: a per-table declaration (storage.notNull) written by the ceremony's step"
],
"recommendation": "A: on a single-posture 18.0 database every table reads 0 NULL (F3), so the per-table gate is the ceremony's verdict, not metadata's; B puts a migration fact into authorable metadata."
}
],
"out_of_scope_findings": [
"class: b · reach: named producer (the boot guard's refusal text) · evidence: organization-ownership-boot-guard.ts:252-258 tells an operator 'keep this one on a 17.x release' for ANY flagged table, which is wrong for an 18.0 database a later 18.x refuses (F2); reachable only once a step flips a declaration, so step 6's PR rewrites it (in the step plan) · carrier: the step 6 child card the seat files · dedupe words: boot guard refusal text 18.0 database, keep on 17.x, organizationOwnershipRefusalMessage",
"class: b · reach: named producer (the ceremony's verified marker) · evidence: boot-guard.ts:318, :348-355 accept any verified marker without comparing CEREMONY_STEPS (organization-ownership-apply.ts:202-212); dormant until step 5 adds steps · carrier: the step 6 child card · dedupe words: ceremony marker step set, verified_at older ceremony, isDataMigrationFlagVerified",
"class: c · reach: named producer (an object declared
tenancy: { enabled: false }) · evidence: injected-system-columns.ts:216-219 drops the column, but the guard checks only systemFields.tenant (boot-guard.ts:205), so that spelling escapes the v17-shape check · carrier: the step 2 child card (step_plan.flags[0]) · dedupe words: tenancy enabled false boot guard, findV17ShapeTables systemFields tenant","noted, not filed: #24489's restore list says one landing but splits across steps 4 and 6 under the line rule (step_plan.steps[3].note) · carrier: the seat (card body edit)"
],
"deviations": [
"The primary checkout was detached (
git checkout --detach, no file change) so the claimed branch could be checked out in a dedicated worktree (../objectstack-issue-24522); the cold build ran in the primary checkout before that, and the worktree replayed it from the shared turbo cache (66/66 cached). A second worktree, ../objectstack-issue-24522-lane, carries the lane branch.","Report composition incident: my first composition command used an UNQUOTED heredoc, so the backtick spans in this report's text ran as shell commands from the primary checkout (read-only gate and test invocations, a
git checkout --detachon an already-detached HEAD) until I stopped it. Measured afterwards: no process left running, both worktrees clean, the primary checkout's HEAD unchanged at 5b14b7f, no remote write. One artifact remains: an EMPTY untracked file named1at /home/user/objectstack (from a-> 1span). The main-checkout guard hook refuses my deletion of it and I did not route around the hook, so it needsrm /home/user/objectstack/1by the seat or the maintainer.","The report JSON carries an extra
pr_lanekey andstep_plan, as the dispatch asked."],
"step_plan": {
"measured_at": "origin/main 5b14b7f (contains f8ef144, #24513); read-only census of HEAD source plus the 18.0 fixture this card commits",
"line_facts": [
"F1 — boot-time schema sync only adds. driver-sql initObjects (sql-driver.ts:12576 createTable, :12610-12627 ADD COLUMN) creates missing tables and columns; a field removed from a declaration leaves its column, reported as drift
unmapped_column→ opdrop_column, categorydestructive(schema-drift.ts:1396-1416). Boot reconcile applies onlysafeentries, only in dev with autoMigrate 'safe', never in production (sql-driver.ts:14148-14163); destructive entries need allowDestructive (:14209-14213), which onlyos migrate apply --allow-destructiveand the ceremony's dropColumnsThroughDrift (organization-ownership-column-drops.ts:34-85) pass. Indexes sync by NAME only (:12646-12655). The boot path never drops a table.","F2 — the boot guard turns a declaration flip into a refusal. findV17ShapeTables (organization-ownership-boot-guard.ts:196-218) flags every registered object with systemFields.tenant === false (:205), no declared organization_id (:206) and a PHYSICAL organization_id column (:215); with no verified sys_migration marker the boot is refused (:360-361), naming a command that refuses in 18.x until step 6 (organization-ownership.ts:1123-1146). Every 18.0 database physically carries organization_id on every injected-tenant table (F1). So declaring an existing table column-less before step 6 refuses every 18.0 database with no way forward: R2 breaks. New sibling tables, created fresh without the column, are safe.",
"F3 — the 18.0 data is stamped, not NULL. In the committed fixture (showcase, single), every reshaped table reads 0 NULL organization_id: 8b stamped the Default Organization on the global settings-audit rows (scope global ×2), on the identity mail (related_object sys_user, 'Reset your ObjectStack password'), on the declared webhook mirror and its two deliveries, on the three sys_secret and two sys_flow_credential rows. So no step-5 move may select its rows by
organization_id IS NULL; each selects by shape (scope = 'global', related_object/template, managed_by = 'package'), and every NOT NULL in 8e can land on a single-posture 18.0 database without an attribution pass.","F4 — nothing certifies an 18.0 database. Only the ceremony ledger writes ORGANIZATION_OWNERSHIP_MIGRATION_ID (organization-ownership.ts:789-799); it is deliberately absent from CREATION_ATTESTED_MIGRATION_IDS (migration.zod.ts:181-185, :244-247). The fixture's sys_migration holds 4 ids, none of them that one. An 18.0 database therefore boots today only because it has no flagged table and no marker (boot-guard.ts:357-360).",
"F5 — a verified marker from a shorter ceremony still certifies. The guard accepts any isDataMigrationFlagVerified row (boot-guard.ts:318, :348-355) and never compares its checkpoint with CEREMONY_STEPS (organization-ownership-apply.ts:202-212). Once step 5 adds steps, a database certified by an older ceremony would skip them; the guard needs a step-set or version check, or a bumped marker id, in the PR that opens the ceremony (step 6).",
"F6 — the declared webhook in an 18.0 database is a CUSTOMIZED mirror once anyone used it: activating it (the showcase docblock's own instruction) writes customized = 1, so D10 fate 2's mirror deletion (which excludes customized rows) does not take it, and its deliveries carry ref_id = the mirror's row id (whk_…), not the name (auto-enqueuer.ts:982-983)."
],
"steps": [
{
"step": 1,
"name": "8b-0g′ — declared webhooks from the registry",
"reshapes": [
"sys_webhook (plugin-webhooks/src/sys-webhook.object.ts; injected tenant column; inventory :548-555, fate attribution,
declaredcategory kept)","sys_http_delivery (service-messaging/src/objects/http-delivery.object.ts; inventory :547, anchor parent('ref_id','sys_webhook'))",
"sys_metadata_activation gains rows of metadata_type 'webhook' (free text, no schema change; tenant:false already)"
],
"expand_form": {
"new_write_shape": "bootstrapDeclaredWebhooks (webhook-outbox-plugin.ts:322) stops writing mirrors; a new declared-webhooks.ts (clone of plugin-sharing/src/declared-sharing-rules.ts) projects registry webhooks with id = name, served in the Default Organization under single (auto-enqueuer admitsOrganization refuses an organization-less subscription against a stamped event); the switch is an activation row of type webhook (catalog-activation.ts CATALOG_ACTIVATION_SUBJECTS gains it); a new delivery carries ref_id = the webhook's name; credentials come from the declaration, not a mirror's sys_secret.",
"dual_read": "AutoEnqueuer.doRefresh (auto-enqueuer.ts:388-432) merges the registry projection with sys_webhook rows and skips an UNcustomized mirror (managed_by 'package', customized not true) whose name is declared — or every event delivers twice; a customized mirror (F6) keeps winning over its declaration until the ceremony; createWebhookRedeliverGuard (redeliver-guard.ts:106-108) looks a ref_id up by row id first, then by declared name."
},
"ceremony_must_later": "Re-point sys_http_delivery.ref_id from the mirror's row id to the webhook name, verify, THEN delete the uncustomized mirrors (a mirror-deletion departure on inventory :548-555, gated like rewrite → mirror-deletion); convert each customized mirror under regime C (an activation row for an
activechange, a clone under a new name for any other edit — #24435 ruling Q2 A) before deleting it; orphaned mirror sys_secret rows are reported or deleted with their mirror.","schema_sync_today": {
"additive": "none — no DDL in this step",
"removal": "none"
},
"expand_first": "possible",
"pr_cut": [
{
"pr": "1a",
"surface": "plugin-webhooks: new declared-webhooks.ts (~250), auto-enqueuer.ts (+120), redeliver-guard.ts (+40), webhook-outbox-plugin.ts seeder retirement and wiring (±70); metadata/catalog-activation.ts (+40); the inventory departure ROW only as a label (+15); an ADR-0087 retirement entry if the seeder's authorable docblock path counts",
"size": "~1.0-1.3k incl. tests; 5 test files name bootstrapDeclaredWebhooks, 9 AutoEnqueuer, 28 sys_webhook",
"proof": "R1: webhook-materialization dogfood rewritten to the registry read on a fresh boot. R2: this harness unchanged and green — the fixture's customized mirror and its 2 deliveries keep every row and key column; the PR adds one harness case that the fixture's customized webhook still dispatches exactly once on a task change. R3: harness case 4 unchanged. R4: serve.ts untouched; serve-walled-posture-refusal.test.ts green."
}
]
},
{
"step": 2,
"name": "8b-0e′ — sys_secret, the settings-audit sibling, sys_flow_credential",
"reshapes": [
"sys_secret (platform-objects/src/system/sys-secret.object.ts; injected column; inventory :622)",
"sys_setting_audit (platform-objects/src/system/sys-setting-audit.object.ts; inventory :621) and a NEW deployment-level sibling for its global rung",
"sys_flow_credential (service-automation/src/sys-flow-credential.object.ts; injected; unique 'global' index flow_name,state,position; inventory :624)"
],
"expand_form": {
"new_write_shape": "The audit writer (settings-service-plugin.ts:600, buildSettingAuditWriter) routes scope === 'global' to the new sibling (systemFields.tenant:false, same fields; its own column-drop inventory row next to :621, a
moveddeparture where scope = 'global'). sys_secret and sys_flow_credential get NO new write shape in this step: their readers are already organization-agnostic (every sys_secret read is by id under privilege — engine.ts:11117-11121, settings-service-plugin.ts:494, datasource-secret-binder.ts:141; flow-credential-channel.ts has 0 organization reads), and 8b keeps stamping them while the column is declared.","dual_read": "Settings history readers union the sibling with sys_setting_audit WHERE scope = 'global' (F3: the 18.0 global rows are stamped, so the selector is scope, never NULL); the sibling joins SETTINGS_READ_DOOR_OBJECTS (settings-read-door.ts:43-47) and the manifest."
},
"ceremony_must_later": "Move sys_setting_audit rows WHERE scope = 'global' to the sibling (copy, read back, delete — runSettingsMove, organization-ownership-apply.ts:2034-2154, is the pattern). Drop organization_id from sys_secret and sys_flow_credential through the column-drops step, which needs their declaration flipped to tenant:false IN THE SAME RELEASE as the open ceremony (F2) — inventory :622/:624 become column-drop then.",
"schema_sync_today": {
"additive": "the sibling is created by createTable; nothing else changes",
"removal": "flipping sys_secret / sys_flow_credential to tenant:false leaves their physical column (warned destructive drift, never applied at boot) AND trips the boot guard on every 18.0 database (F2)"
},
"expand_first": "partly impossible — see flags",
"pr_cut": [
{
"pr": "2a",
"surface": "platform-objects: new sys-platform-setting-audit object (~180); service-settings: writer branch (+30), read door and manifest (+5); inventory rows (+15)",
"size": "~0.4-0.6k incl. tests; 15 test files name sys_setting_audit, 5 buildSettingAuditWriter",
"proof": "R1: a fresh boot writes a global change to the sibling (unit + dogfood settings-config-change-audit extended). R2: harness green — the fixture's 4 sys_setting_audit rows (2 global, 2 tenant) keep every key column; the PR adds a harness case reading the 18.0 global history through the dual read. R3/R4: untouched surfaces, harness case 4 and serve refusal tests green."
},
{
"pr": "2b",
"surface": "the tenant:false flip of sys_secret and sys_flow_credential (2 object files ±10, inventory :622/:624 → column-drop)",
"size": "~0.1k + guard/ceremony tests",
"proof": "MOVES to step 6 (lands with the open ceremony); before it, R2 cannot hold (F2) — the harness's case 1 is the tripwire that refuses it on any earlier PR"
}
]
},
{
"step": 3,
"name": "8b-0d′ — the identity-mail sibling",
"reshapes": [
"sys_email (platform-objects/src/audit/sys-email.object.ts; injected column; inventory :546, anchor subject(related_object, related_id))",
"a NEW deployment-level sibling of sys_email with the same row shape"
],
"expand_form": {
"new_write_shape": "plugin-auth's four identity send sites (auth-manager.ts:2012 reset / account invitation, :2096 verification, :4118 magic link, :5987 change-email; all thread authMailOrganizationArg today, :5653-5656) send with a deployment-level flag that persists to the sibling with no organization; the organization invitation (:3867) stays on sys_email.",
"dual_read": "plugin-email's persistence and every reader of the outbox take the target object: the drain hook (email-plugin.ts:727/:741), the queue consumer (:812), outbox-sweep.ts:180, attachment-reclaim.ts:202/:256, internal-header-readback.ts:98/:133 — each runs over both tables (or once per table), so an 18.0 identity mail row still in sys_email keeps being sent, retried and reclaimed."
},
"ceremony_must_later": "Move sys_email rows that are identity mail to the sibling — selected by related_object = 'sys_user' or template, NOT by NULL (F3: the fixture's reset mail is stamped with the Default Organization); runSettingsMove's copy/read-back/delete shape.",
"schema_sync_today": {
"additive": "createTable for the sibling",
"removal": "none — sys_email keeps its column (tenant mail stays NOT NULL-bound)"
},
"expand_first": "possible",
"pr_cut": [
{
"pr": "3",
"surface": "platform-objects: sibling object (~250); plugin-email persistence and reader seams across ~6 files (~250); plugin-auth auth-manager.ts (~40); inventory rows (+20)",
"size": "~0.7-0.9k incl. tests; 69 test files name sys_email (8 in plugin-email), 35 sendTemplate",
"proof": "R1: fresh sign-up and reset land in the sibling (plugin-auth mail tests + a dogfood case). R2: harness green — the fixture's 2 sys_email rows (an invitation, a reset) keep every key column; the PR adds a harness case that the 18.0 reset row is still readable through the outbox's dual read. R3/R4 untouched."
}
]
},
{
"step": 4,
"name": "the walled lift — 8b's walled half plus the walled part of #24489",
"reshapes": [
"no schema or data reshape; one behaviour flips: under group/isolated an organization-less system insert into one of the 35 newly reached platform objects is refused instead of landing NULL (engine.ts:7251, the
!== 'single'exit)"],
"expand_form": {
"new_write_shape": "none — a refusal, which steps 1-3 make safe by moving the walled writers with no organization (identity mail, global audit, declared webhooks) to sibling or registry shapes first",
"dual_read": "none"
},
"ceremony_must_later": "Handle walled 18.0 databases that exist anyway (scope note item 3: embedded hosts — createStandaloneStack, adapter-hono — were never refused in 18.0), so their NULL rows go through the ceremony's attribution/report fates under the walled posture.",
"schema_sync_today": {
"additive": "none",
"removal": "none"
},
"expand_first": "not applicable (no data shape); strictly after steps 1-3",
"pr_cut": [
{
"pr": "4",
"surface": "objectql engine.ts:7251 (1 condition) and its docblocks; PLATFORM_OBJECT_TENANCY keeps its every-posture reader (the bypassTenantAudit mute, engine.ts:6963-6969) — only the walled reader retires; #24489 items 2 and 3: delete walledPostureRefusal (serve.ts:2419-2423, :6255-6286) and serve-walled-posture-refusal.test.ts, restore the 7 walled e2e cases (cli/test/serve-organizations-host-resolution and -mount-failure) from the parent of #24480's squash; the walled docs sentences (tenancy-modes.mdx, environment-variables.mdx posture lines)",
"size": "~0.3-0.5k incl. tests, plus whatever of 164 walled-posture test files do an organization-less system insert (NOT MEASURED per file)",
"proof": "R1: single and walled fresh boots. R2: harness green (single fixture; unchanged). R3: harness case 4 green — the ceremony still refuses. R4 flips by ruling: the restored e2e cases boot a walled os serve."
}
],
"note": "#24489 says one landing for all six restore items; under the line rule items 1, 4 and 6 (the ceremony's re-registration, dogfood step 2 back on the binary, the import swaps) and the upgrade docs sentences belong to step 6, not 4. The card's body needs that split."
},
{
"step": 5,
"name": "the ceremony steps behind the refusal — moves and deletes, 9c, 8e NOT NULL",
"reshapes": [
"sys_webhook / sys_http_delivery (step 1's ceremony half)",
"sys_setting_audit global rows → sibling (step 2)",
"sys_email identity rows → sibling (step 3)",
"sys_sharing_rule customized copies → regime C clones (9c)",
"organization_id NOT NULL on every cleared tenant table (8e)"
],
"expand_form": {
"new_write_shape": "none at runtime — every addition is a ceremony step inside OrganizationOwnershipCeremony, still unreachable from the registered command (organization-ownership.ts:1123-1146 refuses)",
"dual_read": "steps 1-3's dual reads stay"
},
"ceremony_must_later": "This IS the ceremony. Today's steps (organization-ownership-apply.ts:202-212): attribution, authored-conversion, metadata-promotion, template-promotion, settings-move, rewrite, mirror-deletion, history-renumber, column-drops. Missing: the webhook re-source + mirror-deletion departure; the two sibling moves; 9c's copy migration (each stored row holding a declared rule's name becomes a clone under a new name plus an activation row active:false — today it is withheld at load; the runtime guard half of #24421 is its own PR, see cut); and a NOT NULL step — the plan computes notNull.willReceive (organization-ownership-plan.ts:1096-1129, :1520-1522) but no step applies it, and the repo carries no 'NOT NULL step' text (0 hits).",
"schema_sync_today": {
"additive": "n/a",
"removal": "n/a — but 8e collides with sync: the differ proposes tighten_not_null only when the declaration says storage.notNull, and the injected TENANT_SCOPE_FIELD_DEF (spec/src/data/injected-system-column-provenance.ts:105-115) is required:false with no storage; a NOT NULL applied by raw DDL reads as
relax_not_nullneeds_confirm drift on every later boot (schema-drift.ts:952-990), whichos migratecould undo. SQLite applies NOT NULL by table rebuild (rebuildSqliteTablePatched, sql-driver.ts:14526); the Turso remote face cannot rebuild (schema-drift.ts:406-412)."},
"expand_first": "not applicable (ceremony-only); 8e needs a decision first",
"pr_cut": [
{
"pr": "5a",
"surface": "ceremony: webhook ref_id re-source + mirror-deletion departure (apply + plan + inventory)",
"size": "~0.25-0.4k + ceremony tests",
"proof": "R2: harness green (nothing runs at boot); the ceremony integration tests run the class directly over a copy of THIS fixture (customized mirror, 2 deliveries). R1/R3/R4 unchanged."
},
{
"pr": "5b",
"surface": "ceremony: settings-audit global move + identity-mail move (runSettingsMove pattern ×2)",
"size": "~0.3-0.5k + tests",
"proof": "as 5a; fixture rows: 2 global audit, 1 reset mail"
},
{
"pr": "5c",
"surface": "9c (#24421): the runtime guard and reconcile (plugin-sharing, ~0.3-0.5k, its own measured Clause-②) and the ceremony's copy migration (~0.2-0.3k)",
"size": "~0.5-0.8k",
"proof": "R2: harness green — the fixture's runtime sharing rule keeps its row; a fixture regeneration adding a customized declared-rule copy is the step's first commit if the ceremony half needs one"
},
{
"pr": "5d",
"surface": "8e: the NOT NULL step (after column-drops) plus the differ/declaration change",
"size": "~0.2k + the differ change (UNMEASURED until the decision)",
"proof": "blocked on the decision flagged below"
}
]
},
{
"step": 6,
"name": "opening the ceremony (v17 and 18.0 databases)",
"reshapes": [
"the registered
os migrate organization-ownershipruns the ceremony again; the boot guard's verdict on an 18.0 database changes; sys_secret / sys_flow_credential flip to tenant:false (2b) so column-drops takes their column"],
"expand_form": {
"new_write_shape": "n/a — this is the contract step the line defers everything to",
"dual_read": "n/a"
},
"ceremony_must_later": "n/a — opened here",
"schema_sync_today": {
"additive": "n/a",
"removal": "the flipped tables keep their physical column until the ceremony's column-drops step (F1); the guard reads that column as the trigger (F2)"
},
"expand_first": "impossible by definition: R2 must be RESTATED here",
"pr_cut": [
{
"pr": "6",
"surface": "#24489 items 1, 4, 6 (delete MigrateOrganizationOwnership organization-ownership.ts:1097-1146, default export back to OrganizationOwnershipCeremony :319, release-refusal test, dogfood boot-refusal-order step 2 back on the binary, 19 import sites optional); the guard's 18.0 verdict (F4) and the step-set check (F5); refusal text 'keep on 17.x' (boot-guard.ts:252-258) rewritten for 18.0 databases; os dev's automatic upgrade (dev-organization-ownership-upgrade.ts:88-94) works again; step 2b's flip; the upgrade docs sentences",
"size": "~0.6-1.0k incl. tests",
"proof": "R1: a fresh database boots twice. R3 flips: v17 refused → ceremony → boots. R2 restated: the harness's case 1 becomes 'refused, then the ceremony, then boots with every row' — the harness is rewritten in THIS PR (its case 4 too), by name. R4 already lifted at step 4."
}
],
"decision_needed": "How an 18.0 database is recognised: (a) a birth marker written by the GUARD itself in start() on a datastore created from empty (engine.wasDatastoreCreatedFromEmpty, objectql engine.ts:12624-12636) from step 6 on, with every 18.0 database (no marker) refused until the ceremony — needs a ruling, since it amends the migration.zod.ts:181-185 prohibition, and must handle a telemetry sibling that makes 'created from empty' false; or (b) shape probes extending findV17ShapeTables (declared-webhook mirrors, global audit rows, identity mail, customized sharing copies), which keep a clean 18.0 database bootable but are partial by construction."
},
{
"step": 7,
"name": "8c and 8d, plus removing the dual reads",
"reshapes": [
"8c: the two tenant orWhereNull arms in driver-sql applyTenantScope (sql-driver.ts:15601, :15619; Turso reuses it, turso-driver.ts:2538), resolve-authz-context.ts grantAppliesInTenant :861-863 / :918 / :1436-1441, sharing-rule-service.ts criteriaContext :1705-1709",
"8d: ADR-0120 D3 COALESCE null-safe unique indexes (47 nullSafe hits in sql-driver.ts, 47 in schema-drift.ts, 24 in Turso) and the global sentinel (~110 non-test hits across 10 files, 38 test files)",
"steps 1-3's dual reads (auto-enqueuer mirror merge, redeliver id-then-name lookup, audit history union, outbox two-table readers)"
],
"expand_form": {
"new_write_shape": "none — removals, safe only on databases the guard certified (after step 6)",
"dual_read": "removed"
},
"ceremony_must_later": "8d: rebuild the null-safe indexes on NOT NULL tables, or the differ must learn the equivalence first: buildIndexName ignores null-safety (schema-drift.ts:1460-1466), so changing the declaration keeps the name and emits recreate_index, destructive for a unique index (:2496-2545) — an error-severity drift on every 18.0 database that nothing auto-applies.",
"schema_sync_today": {
"additive": "none",
"removal": "an index definition change under an unchanged name is destructive drift, never applied at boot"
},
"expand_first": "not applicable (contract removals after certification); 8d needs the differ change first",
"pr_cut": [
{
"pr": "7a",
"surface": "8c driver arms + authz NULL readers + criteriaContext",
"size": "~0.6-0.9k incl. tests",
"proof": "R2: harness restated at step 6 (ceremony then boot) stays green; R1/R3/R4 unchanged"
},
{
"pr": "7b",
"surface": "8d: the differ equivalence (COALESCE vs plain on a NOT NULL column) first, then the declarations and global",
"size": "~1.0-1.5k, may split",
"proof": "as 7a; the fixture holds NO sys_sequences/global counter rows today (showcase writes none) — 7b's first commit regenerates the fixture with an autonumber write so the harness covers it"
},
{
"pr": "7c",
"surface": "remove steps 1-3's dual reads",
"size": "~0.2-0.4k",
"proof": "as 7a"
}
]
}
],
"flags": [
"Step 2 (sys_secret, sys_flow_credential): expand-first is IMPOSSIBLE for the column removal — declaring them tenant:false refuses every 18.0 database at boot (F2). Alternative: keep the declaration through steps 2-5 (readers are already organization-agnostic), and land the flip WITH the open ceremony at step 6 (PR 2b moves there). ⛔ Not via
tenancy: { enabled: false }: the guard checks only systemFields.tenant (boot-guard.ts:205), so that spelling would skip the guard — a gap to close, not a route.","Step 1: the registry read is UNSAFE without the uncustomized-mirror skip (double delivery) and without serving the projection in the Default Organization under single (admitsOrganization would drop every stamped event). Customized mirrors (F6) must keep winning until the ceremony converts them.",
"Step 5, 8e: NOT NULL cannot be expressed per table in metadata today (one injected definition, required:false). Decision needed before 5d: a per-table declaration, or the differ treating the ceremony as the owner of organization_id nullability. Without it a ceremony-applied NOT NULL reads as relax drift on every later boot.",
"Step 6: R2 cannot stay 'boots without a ceremony' — the line's own rule makes an 18.0 database take the upgrade path. The harness is restated in step 6's PR. Decision needed on (a) birth marker vs (b) shape probes; plus F5's step-set check.",
"Step 7, 8d: same-name index redefinition is destructive drift; teach the differ the equivalence before changing declarations.",
"#24489 cannot be one landing under the line rule: its walled items go at step 4, its ceremony items at step 6."
]
}
}
Generated by Claude Code