Skip to content

feat(cli,runtime)!: the upgrade ceremony opens — v17 and 18.0 databases take it, and a server serves only a database it created or one the ceremony certified (18.x steps 5 and 6) - #24681

Draft
objectstack-fleet[bot] wants to merge 38 commits into
mainfrom
claude/issue-24581-ceremony-open
Draft

objectstack-fleet[bot] wants to merge 38 commits into
mainfrom
claude/issue-24581-ceremony-open

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Closes #24581
Closes #24582
Part of #24421
Part of #24489
Clause-②: yes (narrowing)

18.x line, steps 5 and 6 as ONE landing (ruling 6270035914 on #15194), under rulings 6266750393, 6267391172 and 6269955919 (and its amendment). Step 0's plan: os-dev-report 6268559156 on #24522. Seat note 6271949316 on #24581 holds what steps 1–3 left the ceremony to do.

Base and merge gate. The branch started from origin/main merged with #24588's head (step 1), because 5a reads its declared-webhook source. #24588 has since landed (191e0430), and main is merged in at d6195b90. ⛔ It lands only after step 4 (#24580) has merged and this branch has merged main again. It meets step 4 only on serve.ts, which this branch does not touch. The diff is 3,534 changed lines (+2,797 / −737) over 66 files, which is over the 3,000-line human-merge line.

Restated from #24635 (on main). That PR taught four surfaces to say "18.0 does not upgrade; keep a v17 database on 17.x": the drop-gate refusal of os migrate apply --allow-destructive, os dev's way-on line, the refusing class's help examples, and that class's pin. That is false once the ceremony is open. The drop refusal and os dev now name the ceremony's steps again, and their pins say so (apply.organization-ownership-refusal.test.ts, dev-organization-ownership-upgrade.test.ts). The --json detail keeps #24635's wording ("No run of the organization-ownership ceremony is recorded…"). The refusing class and its pin stay deleted.

What changed

os migrate organization-ownership runs again (#24489 items 1, 4, 6)

  • The refusing subclass MigrateOrganizationOwnership and ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE are deleted. The default export is OrganizationOwnershipCeremony again.
  • The release-refusal test is deleted. The 16 test files import the default again.
  • The boot-refusal-order dogfood runs its step 2 through the real binary, and step 2a is dropped.
  • os dev's automatic upgrade works again: it runs the binary, which now runs the ceremony.

Four new ceremony steps (shape generation 1)

CEREMONY_STEPS order: attribution, authored-conversion, metadata-promotion, template-promotion, settings-move, sibling-moves, declared-webhooks, declared-rule-copies, rewrite, mirror-deletion, history-renumber, column-drops, not-null.

Every new step selects by shape, never by organization_id IS NULL (F3).

  • 5b sibling-moves. Rows move id for id. Each is copied over the columns both tables carry (never organization_id), read back, then deleted with its selector re-asserted. A row already in the sibling is not copied twice.
    • sys_setting_audit WHERE scope = 'global' moves to sys_platform_setting_audit.
    • Identity mail moves from sys_email to sys_identity_email. It is selected as related_object = 'sys_user' (reset, verification, account invitation, change-email notice), plus the magic link. Selector gap, defined here: an 18.0 magic-link row has no related record and no template column, so it is selected by related_object IS NULL AND related_id IS NULL and a body carrying better-auth's sign-in path /magic-link/verify?token=. The organization invitation (sys_invitation) is tenant mail and stays.
    • First: pending and running mail jobs (email.send.async, email.attachment.reclaim) that name a moving row get object: 'sys_identity_email' in their payload and metadata, and the sibling's idempotency-key spelling. Without this, a send finds no row and a reclaim takes the row-gone branch and deletes attachment content.
    • After each chunk: sys_activity rows about a moved row (every rotation shard) name the new object. sys_audit_log is a ledger and is not rewritten.
    • Exposure (measure(plugin-email): who can list sys_email rows that carry identity mail (reset and verification links) — sys_email declares no requiredPermissions #24625): nothing on sys_email changes. The moved rows land in a table with requiredPermissions, and the re-pointed activity rows follow them there, so the exposure is not widened. It is not fixed either.
  • 5a declared-webhooks. For each sys_webhook row with managed_by package or platform under a name a package still declares:
    • A pristine copy: its deliveries are re-sourced to declared:NAME, then the row is deleted, then the sys_secret cipher it referenced is deleted once no webhook row references it.
    • Customized only in its switch (every mapped column still equals the definition_json snapshot the 18.0 seeder last wrote, and no credential is stored): the switch becomes the declaration's sys_metadata_activation row, the deliveries are re-sourced, and the row is deleted. The harness fixture's row is this case.
    • Any other edit: the row is renamed in place to NAME_custom with managed_by 'admin', and the declaration is switched off. Its id, credentials and deliveries stay its own, so the deliveries' tail already names the clone's row id. A row holding a credential is always settled this way, because a stored secret cannot be compared without decrypting it.
    • A seeded row whose name no package declares is an ordinary webhook, as in 18.0. It stays and is reported.
    • Mirror predicate aligned: classifyDeclaredNameHolder treats platform like package (a new shared isSeededProvenance, also used by the provenance hook).
    • DECLARED_WEBHOOK_REF_PREFIX / declaredWebhookRefId are now exported by plugin-webhooks. The ceremony's copy of the spelling is pinned equal to them.
  • 5c declared-rule-copies (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). The runtime half already landed in plugin-sharing, runtime: sharing_rule under regime C, so every same-name row is refused and declared rules are switched through the activation ledger (ruling A on #24378) #24428, measured: the same-name guard, the ledger switch and the load report. This step is the copy migration, ruling A item 3. Each sys_sharing_rule row under a declared rule's name that is not a pristine mirror (a customized copy, or an admin rule a later declaration collided with) is renamed in place to NAME_custom, managed_by 'admin'. Its id-keyed grants stay its own. The declaration is switched off (sharing_rule activation row, active: false), and grants still naming the declaration itself are removed. Pristine mirrors stay the mirror-deletion step's.
  • 5d not-null (8e, ruling ② A). After column-drops, every table the post-step plan clears (notNull.willReceive, zero NULL rows) takes organization_id NOT NULL. It goes through the drift apply door of the driver that holds the table: the SQLite rebuild, or SET NOT NULL. Results:
    • A table the composition does not declare is not-declared.
    • A transport with a declared apply limit (the Turso remote face) is unsupported.
    • A rotation shard is unsupported: the door rebuilds only declared tables, and new shards are created nullable.
    • The differ (diffManagedTable) takes the table's tenant column (ceremonyOwnedNullability, passed by SqlDriver.detectTableDrift from resolveTenantField) and proposes no relax_not_null for it. No metadata changes.
  • The CeremonyCheckpoint carries shapeGeneration. DeclaredSharingRulesPort.rules() returns packageId. Two new ports: DeclaredWebhooksPort (the command reads the registry, parsed with WebhookSchema) and NotNullPort.

The guard's verdict on an 18.0 database (F4, F5; ruling ① A)

  • ORGANIZATION_OWNERSHIP_SHAPE_GENERATION = 1, plus OrganizationOwnershipBirthMarkerSchema and organizationOwnershipShapeGenerationOf in @objectstack/spec/system. The migration.zod.ts prohibition is amended for this marker only.
  • Birth marker. On a datastore this boot created from empty (judged on the driver of the store that holds sys_migration, so a telemetry sibling neither ages a fresh primary nor freshens an old one), with no marker and no v17-shape table, the guard writes the ceremony's row once: verified, details.birth = { shapeGeneration, platformVersion, createdAt }. commit is absent, because no build records it. A failed write refuses the boot.
  • The rule. The guard decides on shapeGeneration alone. A verified marker at the current generation boots. A database with no marker (every 18.0 one) is refused, and so is one verified below the current generation (F5). Unverified or unreadable markers are refused as before.
  • Every CLI boot writes the birth half too. This is OrganizationOwnershipBirthMarkerPlugin in createStandaloneStack. It never refuses. Without it, an install whose first command is os migrate apply or os seed would be refused by its first os serve. The ceremony's own boots defer DDL, so they never trip it.
  • The refusal text no longer says to keep a database on 17.x. One sentence covers v17 and 18.0 databases, plus an outdated arm.
  • tenancy: { enabled: false } gap closed: the v17-shape probe reads that spelling and systemFields: false too.

sys_secret / sys_flow_credential flips (step 2's PR 2b, F2)

  • Both are systemFields: { tenant: false }, and their inventory rows are column-drop.
  • The settings writer no longer passes organization_id for sys_secret. SettingsSecretStore.insert no longer takes it.

Inventory

  • sys_setting_audit gains the global-audit-move departure, and sys_email gains identity-mail-move.
  • sys_webhook's declared category now covers package and platform, and its label names the settlement.

Proof

R1 — a fresh boot writes the birth marker and boots, again and again. Pins: organization-ownership-boot-guard.kernel.test.ts (a real kernel) and organization-ownership-boot-guard.test.ts (the CLI birth half).

R2 and R3, restated by name in upgrade-18x-harness.dogfood.test.ts: 8/8 pass.

  • 1. R2, restated: HEAD refuses the 18.0 database at boot, naming the ceremony, and writes no marker.
  • 1b. R2, restated: the ceremony, through the real binary in the showcase's directory, plans and applies the database and certifies it at this release's shape generation.
  • 1c. R2, restated: HEAD then boots over it.
  • 2: it serves the 18.0 data.
  • 2b: the declared webhook dispatches exactly once, from its declaration (declared:showcase_task_changed).
  • 3: every 18.0 row of every reshaped table is where the ceremony put it, with its key columns:
    • moved: identity mail and the global audit rows;
    • re-sourced: the deliveries;
    • deleted: the webhook copy, whose switch is kept;
    • organization_id gone on sys_secret / sys_flow_credential;
    • NOT NULL on the tenant tables.
  • 4. R3, restated: a v17-trait copy is refused, takes the ceremony, and boots.

A shorter ceremony's marker is refused (F5): organization-ownership-boot-guard.test.ts.

The ceremony's own pins: organization-ownership-apply.generation-1.test.ts, 16 cases over an 18.0-shaped database (every row stamped), one or more per step, plus idempotence and the walled block. Also organization-ownership-not-null.test.ts, on a real SQLite driver: NOT NULL is applied and the next drift scan reports nothing for it.

Ablations (scripts/ablation-replace.mjs). Every leg is committed first and restored to blob == HEAD, with git diff HEAD empty after the last leg:

ablated red
guard: the generation rule (>= current → >= 0) 1 (F5)
guard: birth only on a store created from empty 2
guard: the tenancy.enabled: false spelling 2
5b: the global-audit selector 3
5b: the magic-link selector 3
5b: the mail-job rewrite 2
5a: the delivery re-source 2
5a: the switch-only reading 2
5c: the declaration switched off 2
5d: NULL rows hold NOT NULL back 1
differ: the ceremony-owned nullability exemption 1

Tests. Run on this branch before the main merge, then the touched sets again after it at d6195b90. Exit codes were captured to files before any pipe.

  • @objectstack/runtime: full suite, 428 files, 7,492 passed, 19 skipped.
  • @objectstack/cli: full suite. The first run went red in 72 cases, mostly whole-file import errors while the spec dist was being rebuilt underneath it. A re-run of every failed file left 6, all fixed:
  • @objectstack/driver-sql: 234 files, 3,678 passed. Two drift fixtures were re-keyed off organization_id, and the tenant-column exemption pin was added.
  • The other suites touched:
    • service-settings: 892 passed (one pin restated: the cipher names no organization).
    • platform-objects: 1,192 passed (one control moved to sys_setting_audit, plus a sys_secret pin).
    • service-automation: 2,580 passed.
    • plugin-webhooks: 212 passed.
  • Dogfood, after the merge: upgrade-18x-harness, organization-ownership-boot-refusal-order, settings-audit-global-rung-sibling and webhook-materialization: 4 files, 17 passed, 50 s.
  • typecheck: exit 0 for cli, runtime, driver-sql, plugin-webhooks and service-settings. eslint --no-inline-config over the changed .ts files: exit 0.

Gates. node scripts/pm/dispatch-gates.mjs --commands derived 137 commands before the merge, and 132 after it, all among the 137.

  • 131 of the 137 exited 0 on the first sweep.
  • The 6 that did not:
    • check-platform-object-tenancy-census and its self-test: the census was regenerated with --write (sys_secret and sys_flow_credential leave reach, 44 → 42).
    • check:engine-double-contract: the guard double's findOne now asserts the predicate, and the pinned ledger was rewritten with --write.
    • check:spec-parsed-alias: the birth marker's z.input alias, with a Parsed alias beside it.
    • check:test-source-alias: a CLI vitest alias for @objectstack/plugin-webhooks.
    • check-plugin-teardown-shape --self-test: exit 3, NOT MEASURED. Its positive control needs history this shallow clone lacks; this is checker health, not this diff.
  • Each fixed one re-ran exit 0 after the merge. So did check:adr-0087-registration, check:tenant-audit-census, check:platform-checklist, and the spec check:generated (14 artifacts up to date).
  • check-governed-merges --branch: 0 of 95 paths on the register before the merge. The size decides: human merge or an authorized approval.

Decisions taken, for the seat

  • Walled databases. A customized copy of a declared webhook or sharing rule BLOCKS its step under group / isolated, naming the rows and the remedy: rename it to a name no package declares, or delete it, then re-run. The declaration's switch is deployment-wide (ruling A item 1: no per-organization switch-off), so switching it off on the operator's behalf would remove it from every organization. Pristine copies are still deleted under a wall.
  • Clones are renamed in place, not inserted under a new id. The end state is the regime's (a row under a new name plus the declaration off), and every grant and delivery keyed by the row's id stays valid with no rewrite.
  • sys_webhook stays a category, not a departure. A departure withholds its NULL rows from attribution, and a seeded row no package declares is a live webhook that must be attributed like any other. The settlement selects by shape in its own step.
  • The magic-link selector is the sign-in path in the body (see 5b).

Acceptance notes

  • Files outside the claimed surface:
    • the spec's generated artifacts;
    • docs/qa/platform-checklist/areas/cli.json (item cli.organization-ownership-ceremony, revision 3: its pin organization-ownership.release-refusal.test.ts is deleted here);
    • content/docs/deployment/cli.mdx (the 18.0 refusal sentences);
    • two driver-sql drift tests whose relax_not_null fixture used organization_id, re-keyed to another column, plus a new pin that the tenant column is exempt;
    • standalone-stack.ts (the birth half).
  • Not touched: docs/adr/**, content/docs/releases/, serve.ts, step 4's walled sentences in tenancy-modes.mdx / self-hosting.mdx / environment-variables.mdx.
  • A pending delivery re-sourced by 5a keeps its partition_key. It is still claimed and sent, by that partition's worker.
  • The harness fixture holds no 9c copy (its one sharing rule is an admin rule under an undeclared name), so 5c is pinned by the unit fixture alone. A fixture regeneration with a customized declared-rule copy would cover it on the harness.

Generated by Claude Code

claude added 30 commits October 19, 2026 13:06
…r retired; webhook activation door

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…he 18.0 dual read; door pins for webhook

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…asserts no row and a dispatch

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…the checklist, the webhooks guide and the showcase; changeset

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…tcher's switch read

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…posture builds no wall, as its 18.0 seeded copy stamped with the Default Organization was

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…red writes; the declared-webhook double is drivable by the limit gate

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…does not implement

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…authors; checklist and admitsOrganization docblock say a declared webhook is deployment-wide under single

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…the fixture's customized declared-webhook mirror

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…n fail on a double delivery

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…0g-webhooks-registry

# Conflicts:
#	content/docs/permissions/tenant-audit-census.mdx
#	docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
…0g-webhooks-registry

# Conflicts:
#	content/docs/permissions/tenant-audit-census.mdx
#	docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
…-registry' into claude/issue-24581-ceremony-open
…he ceremony, then boot; boot-refusal-order step 2 back on the binary
…el — the cipher names no organization

Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V
Co-authored-by: Claude <noreply@anthropic.com>
…sys_flow_credential leave reach)

Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 02321084fc90276ef297814e454b9ba669e7afe6
Local-runs: none
Scope: packages/spec/src/system/migration.zod.ts + packages/cli/src/utils/organization-ownership-apply.ts, packages/cli/src/utils/organization-ownership-not-null.ts, packages/cli/src/utils/organization-ownership-inventory.ts, packages/cli/src/commands/migrate/organization-ownership.ts, packages/cli/src/commands/migrate/apply.ts, packages/cli/src/utils/dev-organization-ownership-upgrade.ts, packages/runtime/src/organization-ownership-boot-guard.ts, packages/runtime/src/standalone-stack.ts, packages/runtime/src/index.ts, packages/drivers/driver-sql/src/schema-drift.ts, packages/drivers/driver-sql/src/sql-driver.ts, packages/plugins/plugin-webhooks/src/declared-webhooks.ts, packages/plugins/plugin-webhooks/src/webhook-provenance.ts, packages/plugins/plugin-webhooks/src/index.ts, packages/platform-objects/src/system/sys-secret.object.ts, packages/services/service-automation/src/sys-flow-credential.object.ts, packages/services/service-settings/src/settings-service.ts, packages/services/service-settings/src/settings-service.types.ts, packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts, packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts, packages/cli/src/utils/organization-ownership-apply.generation-1.test.ts, packages/cli/src/utils/organization-ownership-not-null.test.ts, packages/runtime/src/organization-ownership-boot-guard.test.ts, packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts, packages/drivers/driver-sql/src/sql-driver-schema-drift.test.ts, content/docs/deployment/cli.mdx, docs/qa/platform-checklist/areas/cli.json, .changeset/24581-organization-ownership-ceremony-open.md; read beside the diff for the judgments below: packages/cli/src/utils/organization-ownership-plan.ts, packages/drivers/driver-turso/src/turso-driver.ts, packages/objectql/src/engine.ts, packages/plugins/plugin-webhooks/src/sys-webhook.object.ts, packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts

Read: cards #24581 and #24582; claims 6276776808 and 6276790571; seat note 6271949316; rulings 6266750393, 6267391172, 6269955919 with its amendment 6270010349, and 6270035914 on #15194; ruling 6278609968 on #24580; step 0's plan 6268559156 (steps[4], steps[5], F1 to F6); the dev's report 6279428591; the Landed records 6279236728 (#24529), 6275112954 (#24530), 6272692402 (#24531); #24489; #24625 (measured record 6278938192, claim 6278944518); the head's check-runs twice. Diff: 66 files, +2,797 / -737, head 02321084 verified; merge-base with origin/main 7c509c286; origin/main at eadea5720.

① Derived judgments

1. 5a, webhooks (organization-ownership-apply.ts, runDeclaredWebhooks 3178-3330). Holds.

  • Selection is by shape: managed_by IN ('package','platform') (SEEDED_PROVENANCE 2985, used at 3187), then only names a package still declares (3199-3200). Never by NULL.
  • Pristine mirrors: deliveries re-sourced first to declared:NAME (3258, DECLARED_WEBHOOK_REF_PREFIX 2992) with a count-back that throws if any still name the row (3226-3240), then the copy is deleted by id with the seeded predicate re-asserted and read back (3241-3247, 3259). Pinned: generation-1 test 312-325 asserts the UPDATE precedes the DELETE, and 351-353 pins the spelling equal to plugin-webhooks' declaredWebhookRefId (now exported, plugin-webhooks/src/index.ts).
  • Customized mirrors: a switch-only edit (isSwitchOnlyEdit 3129-3142: every column the 18.0 seeder mapped, label / object_name / triggers / url / method / description, still equals the definition_json snapshot, and no credential is stored, 3132) becomes the declaration's sys_metadata_activation row with that switch (3257, setActivation 3014-3064 reads back), deliveries re-sourced, copy deleted. Any other edit is renamed in place to a free NAME_custom (3264-3279, freeName 3070-3082 within NAME_MAX), managed_by 'admin', customized false, snapshot stripped of provenance (3265, 3270), and the declaration is switched off (3280). The clone keeps its id, so its deliveries' tail already names the clone's row id, which is what seat note 6271949316 asked for. A credential-bearing row is always a clone (3132; test 355-360). Pinned 327-349.
  • Orphaned ciphers: the sys_secret ids a deleted copy referenced are deleted once no webhook row references them (3288-3305); test 312-325 (secretsDeleted: 1, sec_setting kept).
  • Mirror predicate aligned: classifyDeclaredNameHolder now uses isSeededProvenance (declared-webhooks.ts diff), the same pair the provenance hook stamps (webhook-provenance.ts diff) and the ceremony reads. Inventory sys_webhook category where managed_by in [package, platform] (organization-ownership-inventory.ts diff), kept a category not a departure so an undeclared seeded row (a live webhook) is still attributed; such rows stay and are reported (3201-3208; test 343-348).
  • Post-condition verified: no seeded row holds a declared name after the step (3307-3317). Blocks, writing nothing, when the run has no registry to read (3192-3198; test 387-391).

2. 5b, sibling moves (runSiblingMoves 3456-3521, SIBLING_MOVES 3353-3379). Holds; F3 holds.

  • Global audit rows: scope = 'global' (3358), to sys_platform_setting_audit. Identity mail: related_object = 'sys_user' plus the magic-link clause (3369-3377). Neither reads organization_id; the copy excludes ORG (3478); the delete re-asserts the selector by id (3511); read-back before delete (3506-3509); already-moved rows are not copied twice (3483-3488; test 304-310). Inventory gains the two departures identity-mail-move and global-audit-move (organization-ownership-inventory.ts diff), both as: 'moved'.
  • Mail jobs first: rewriteMailJobs (3393-3425) runs before the chunks (3479), over email.send.async and email.attachment.reclaim pending or running jobs whose {rowId} is a moving row and whose object is absent or sys_email (3413); payload and metadata gain object: 'sys_identity_email', and the idempotency key takes the sibling spelling for both the row and the _attachments key (3417-3419). Test 276-293.
  • sys_activity rows re-pointed on every rotation shard by (object_name, record_id) after each chunk (3510, 3524-3540); the audit ledger is left alone by design. Test 295-302. Seat note said re-point or delete; re-point is done. sys_activity carries only object_name / record_id as its record linkage (plugin-audit/src/objects/sys-activity.object.ts 163, 198), so nothing is left pointing at the old object.
  • Exposure (measure(plugin-email): who can list sys_email rows that carry identity mail (reset and verification links) — sys_email declares no requiredPermissions #24625): nothing on sys_email is widened; the moved rows land behind requiredPermissions on sys_identity_email.

DECISION 2, the magic-link selector (apply.ts 3344-3351, 3369-3377): related_object IS NULL AND related_id IS NULL AND (body_text LIKE '%/magic-link/verify?token=%' OR body_html LIKE ...). Reliability: the link is the mail's purpose, so every rendering of auth.magic_link carries it, and better-auth's verify path is fixed for the 18.0 line; the pattern is bound as a parameter (no placeholder confusion, no _ wildcard in it). What it can misclassify, and in which direction: (a) a false positive, a non-identity mail with no related record whose body quotes a sign-in link (an app or notification mail that pasted one) is moved INTO sys_identity_email, the stricter table (manage_platform_settings), and loses its organization stamp, which under single is the Default Organization; that errs safe for exposure and loses nothing a reader needs. The dev's own out-of-scope finding is the same direction: an app mail sent with a caller-supplied relatedObject 'sys_user' moves too. (b) A false negative, a magic-link row whose body carries no such path (a customized template that omitted the URL text, or a row with related_id set) stays in sys_email; under single it is stamped (F3), so the NOT NULL step is not held back, and its body is covered by the #24625 column-wide gate; under a wall an organization-less miss is unattributable and blocks attribution, which fails closed (see ③3 for the misdirecting remedy). No misclassification moves a row to a LESS gated table. Ablated red (3). Accept.

3. 5c, 9c (runDeclaredRuleCopies 3570-3676). Holds. A copy is any row that is not a pristine mirror (copyWhere 3582-3589, so customized seeded copies and admin rows a declaration collided with), under a name the registry declares (3605-3622). Each is renamed in place to a free NAME_custom, managed_by 'admin', customized false (3645-3657), its id and so its sys_record_share.source_id grants kept; the declaration's sharing_rule activation row is written active: false (3662), and grants naming the declaration itself are removed and counted (3663-3672). Pristine mirrors stay the mirror-deletion step's. Blocks, writing nothing, without a registry while a customized copy exists (3590-3604; declared-rule-mirrors.test.ts diff pins that the step blocks first and mirror-deletion is not reached). Test 362-374 and the idempotence case 426-432.

DECISION 1, the walled block (apply.ts 3209-3219 for webhooks, 3624-3633 for rules; test 376-385). Under group or isolated, a customized copy of a declared item blocks its step before any write, naming each row by id and name and the remedy. Consistency with ruling A (6278609968): the switch is one sys_metadata_activation row, deployment-level and operator-gated under a wall; switching it off for one organization's customization would remove the declared webhook or rule from every organization, which is exactly the silent cross-organization loss #24435 rejected. Under single the ceremony does decide (one organization, one switch), and the end state equals what 18.0 served: the customized row answered and the declaration was withheld; now the clone answers and the declaration is off, so no access changes silently there, and the grants 5c removes are named in the report (grantsRemoved). Pristine copies under a wall are deleted only when no customized copy exists (3209-3219 returns before any write); the PR body's "pristine copies are still deleted under a wall" is true in that case only. Actionable remedy: rename to an undeclared name (step 4's 409 guard refuses only a rename ONTO a declared name, bindDeclaredWebhookNameGuard on #24667; bindDeclaredRuleNameGuard likewise), or delete; then set the switch through the activation door, which under a wall is the operator's, and the operator is who runs the ceremony; then re-run. Each step names its rows. Accept. The remedy would be stronger if the reviewed plan named the copies before --apply (③4).

4. 5d, NOT NULL after the moves (runNotNull 3724-3760; organization-ownership-not-null.ts). Holds under ② A. Candidates are the post-move plan's attribution / report tables with a present column, notNull.willReceive and organizationNull === 0 (3731-3736); every physical table, siblings routed by database (3735, command 866-888). The door is the driver's own drift apply with a ceremony-authored tighten_not_null entry (not-null.ts 60-73): not-declared for an undeclared table (36-39), unsupported for a rotation shard (40-49) and for a transport with driftApplyLimit (55-59, the Turso remote face), failed blocks (apply.ts 3754-3756). The differ exemption is exactly one condition, relax_not_null on fieldName === ceremonyOwnedNullability (schema-drift.ts diff at the one expectNullable && !col.nullable && field.required !== true branch), fed by SqlDriver.resolveTenantField(tableName) (sql-driver.ts diff): a NOT NULL on any other column is still reported (pin: sql-driver-schema-drift.test.ts, note still yields relax_not_null), type, missing-column, unmapped-column and index drift on the tenant column are untouched, and a table whose tenant field is not organization_id or is absent gets no exemption. No metadata changed. not-null.test.ts 54 drives the real door and re-scans. Masks no other drift.

5. Step 6.

  • chore(cli)!: 18.x restore of the 18.0 guards — re-open the upgrade ceremony and walled boots #24489 items 1, 4, 6: MigrateOrganizationOwnership and ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE* deleted, export default class OrganizationOwnershipCeremony (organization-ownership.ts 320); organization-ownership.release-refusal.test.ts deleted; dogfood boot-refusal-order step 2 runs the real binary and 2a is dropped (diff); the 16 test files import the default. DeclaredWebhooksPort (registry first, metadata service second, each item parsed by WebhookSchema, 835-860) and NotNullPort (862-888) wired in the command. os dev names the ceremony again (dev-organization-ownership-upgrade.ts diff) and the drop gate's human face names its two steps (apply.ts diff); both pinned with not.toMatch(/17\.x/).
  • Birth marker and guard: ORGANIZATION_OWNERSHIP_SHAPE_GENERATION = 1, OrganizationOwnershipBirthMarkerSchema {shapeGeneration, platformVersion, commit optional, createdAt}, organizationOwnershipShapeGenerationOf reading birth then checkpoint, else 0 (migration.zod.ts diff). The guard decides on generation alone: verified at or above current boots (boot-guard.ts 542-550); absent plus no v17 table plus the ledger's own store created from empty writes the marker once (551-570, writeBirthMarker 488-514, commit absent because no build records it, 491); otherwise refused, a verified lower generation as outdated naming both generations (571-578, 284-291) which is F5; unverified and unreadable refused as before (306-312, 299-304). The ceremony's checkpoint carries shapeGeneration (apply.ts 374-391, 3957-3960, 4165). The telemetry-sibling case of ruling ①A item 1 is handled by judging the ledger store's own stats (474-482). Every CLI boot writes the birth half: OrganizationOwnershipBirthMarkerPlugin (373-406, never refuses) in createStandaloneStack (standalone-stack.ts diff). Pins: boot-guard.test.ts 162 (R1), 177 (18.0 database refused), 199 (F5), 294 (the birth half), kernel test 174 and 192. But see B1 for the one served transport whose store can never read as created from empty.
  • Flips: systemFields: { tenant: false } on sys_secret and sys_flow_credential (object diffs), inventory fates to column-drop, settings writer no longer stamps the cipher and SettingsSecretStore.insert no longer takes organization_id (service-settings diffs; settings-secret-organization.test.ts restated). Harness case 3 asserts both columns gone (270).
  • Refusal text (271-313) names no release line; one sentence covers v17 and 18.0 databases, plus the outdated arm. Harness 159 pins no 17.x.
  • tenancy: { enabled: false } and systemFields: false read by the v17 probe (declaresNoOrganizationColumn 224-231; test 138).
  • migration.zod.ts prohibition amended for this marker only, stated as "claims no ceremony ran: it records that none was owed", consistent with ①A.

6. The 18.x line rule after this lands. The harness (upgrade-18x-harness.dogfood.test.ts) names R2 and R3: 1. R2, restated (155, refused, no marker written), 1b. R2, restated (168, the real binary plans and applies in the showcase's directory, expectCertified 106-118 reads checkpoint.shapeGeneration equal to the current generation), 1c. R2, restated (176, boots), 2 and 2b (186, 202: one delivery declared:NAME), 3 (231: moved, re-sourced, deleted, columns dropped, NOT NULL on five tenant tables), 4. R3, restated (281: v17 trait refused, ceremony, boots). R1 is pinned by name in the guard's unit test (162) and kernel test (174), not in the harness; the cards asked the harness for R2 and R3 by name, which it holds. R4 is step 4's, in #24667's restored e2e cases. 8/8 reported green after the main merge.

7. Ablations and gates. Eleven legs, each red then restored to blob == HEAD, including the generation rule (F5), birth only on a store created from empty, the tenancy.enabled spelling, both 5b selectors, the mail-job rewrite, the delivery re-source, the switch-only reading, the rule switch-off, the NULL hold-back and the differ exemption; every leg maps to a claim above. Gates: 137 derived, 131 exit 0 on the first sweep, 6 fixed (two census regenerations, the engine-double predicate, the spec parsed alias, the CLI test alias, with check-plugin-teardown-shape --self-test NOT MEASURED on the shallow clone), 132 re-derived after the merge all green; check-governed-merges --branch: 0 of 95 governed paths, 3,534 changed lines so Tier H. Head check-runs at the second read: Lint and Repo Gates, every Lint lane, Type Check source gates and debt ledger, Build Core, Build Docs, Check Changeset, Check PR Size, Governed Surface Queue Guard, the claim guards, Spec property liveness, Dogfood Regression Gate all success; Test Core 1 to 6, Type Check workspace and Type Check consumer gates still in_progress; the opt-in lanes skipped. The verdict below does not wait on them; the seat reads them before queueing.

8. Complete feature. The command runs, the harness upgrades an 18.0 and a v17 database through the real binary and boots them, os dev upgrades in place, the docs and checklist (cli.json revision 3) say the ceremony is open. Nothing dormant on this head.

9. Interactions.

10. Semver and the Clause-② arm. See ②.

② Semver level

minor on eight packages, marked !, under the launch-window convention for breaking changes (ADR-0131 D10; check-changeset-no-major holds the ceiling; Check Changeset success). Consistent with the content: breaking for every database an earlier release created (refused at boot until the ceremony; os migrate organization-ownership exits 0 where 18.0 exited 1 NOT_IMPLEMENTED, and the cli exports ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE / _CODE are deleted); two objects lose their organization column; SettingsSecretStore.insert loses an optional property (narrowing); new additive spec exports ORGANIZATION_OWNERSHIP_SHAPE_GENERATION, OrganizationOwnershipBirthMarkerSchema, organizationOwnershipShapeGenerationOf, runtime OrganizationOwnershipBirthMarkerPlugin, plugin-webhooks DECLARED_WEBHOOK_REF_PREFIX / declaredWebhookRefId / isSeededProvenance. ADR-0087 not-required (no-migration-prescription) is right: no metadata key moves; the data migration is the operator's ceremony, named by the refusal. Clause-② arm: the PR body and changeset read Clause-②: yes (narrowing); reviewFace on the changed paths decides the schema face (migration.zod.ts), so the tier is CONTRACT_REVIEW_TIER whatever that line says; the mixed spelling is noted, not held against the diff.

③ Boundary flags

  1. Merge instructions for the landing (B2): resolve the cli.mdx conflict with feat(objectql,cli,plugin-webhooks)!: 18.x step 4: the walled lift (8b walled half, the os serve lift, the declared-webhook name guard, declared webhooks deployment-wide) #24667 in this PR's favour plus step 4's "every tenancy posture" clause; restate fix(spec): 18.0 upgrade-guide entries say 18.0 supports fresh installs only #24661's seven semantic entries to the ceremony prescription; re-run the harness on the merged tree; re-review at the new head.
  2. Plan versus apply on the identity selector. The inventory departure identity-mail-move.where is related_object = 'sys_user' alone (organization-ownership-inventory.ts diff); the apply adds the magic-link clause (apply.ts 3369-3377). Under single every row is stamped (F3) so the departure counts nothing and the difference is invisible. In a walled 18.0 database (embedded hosts only, which 18.0 never refused), an organization-less magic-link row is not withheld from attribution, has no anchor, and blocks the attribution step with UNATTRIBUTABLE_REMEDY (3794-3797), which tells the operator to assign an organization to a row the next step would move. Fails closed; the remedy misdirects. Follow-up: express the magic-link shape in the departure or name the case in the remedy.
  3. The reviewed plan does not list the 5a / 5c settlements (organization-ownership-plan.ts unchanged): clones, activation rows and removed name-grants first appear in the apply report. Under single the end state equals what 18.0 served; under a wall the step blocks. A plan line naming the copies would let the operator see the switch-off before --apply.
  4. isSwitchOnlyEdit compares against definition_json, itself an editable column (apply.ts 3129-3142). An edit confined to the snapshot while every mapped column still matches reads as switch-only and is dropped with the copy. Setup edits the mapped columns, so this is an unusual path; a customized row whose mapped columns all equal the snapshot could be cloned instead of deleted if that ever matters.
  5. Hosts that compose neither the guard nor the birth plugin (@objectstack/verify's own boots aside): a store they create from empty carries no marker and a later os serve refuses it as an earlier release's. Ruling ①A puts the write on the guard; recordOrganizationOwnershipBirth and assertOrganizationOwnershipMigrated are exported (runtime/src/index.ts) for a host that composes its own boot. Documentation only.
  6. R1 by name lives in the guard's unit and kernel tests, not the harness (which names R2 and R3, as the cards asked). R4 is feat(objectql,cli,plugin-webhooks)!: 18.x step 4: the walled lift (8b walled half, the os serve lift, the declared-webhook name guard, declared webhooks deployment-wide) #24667's.
  7. Check-runs in progress at review time: Test Core 1 to 6, Type Check workspace, Type Check consumer gates. The seat reads them before queueing.
  8. Carried, not blocking: a harness fixture regeneration with a customized declared-rule copy so 5c is pinned on the harness too (the dev's out-of-scope finding b); ADR-0131 D7's dated notes (Q2 A, Q4 A, now 5a / 5c / 8e) for the Tier H ADR PR; step 7 (refactor(drivers,objectql,plugin-security)!: 18.x step 7: retire the organization-less readers (8c), COALESCE and __global__ (8d), and steps 1–3's dual reads #24583) removes the dual reads.
  9. Size: 3,534 changed lines, Tier H: a human merge or an authorized approval. 0 governed paths. The PR is a draft.

Implemented-by: claude/issue-24581-ceremony-open
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: FAIL

  • B1. R1 is broken on the Turso remote served face: a fresh libsql:// install is refused as a database an earlier release created. The guard writes the birth marker only when the ledger's store reads as created from empty: ledgerStoreCreatedFromEmpty (organization-ownership-boot-guard.ts 474-482) takes the driver's getSchemaSyncStats, needs existing at zero and created above zero, and falls back to engine.wasDatastoreCreatedFromEmpty() which needs the same (objectql/src/engine.ts 12646-12657); "cannot say" is no (551, pinned at boot-guard.test.ts 186). The Turso remote face always answers { created: 0, existing: 0 } by contract: getSchemaSyncStats: 'inherited' with "the remote doors count nothing" (driver-turso/src/turso-driver.ts 1081-1083), although its own initObjects: 'remote' (1059) probes table existence with hasTable (2699) and so could count. The remote face speaks sqlite (dialectName), so the guard is not inert there, and os start serves libsql:// URLs (standalone-stack.ts 20, 51). On main such a fresh install boots (no marker, no flagged table). On this head it is refused with "an earlier release created it", which is false, and the only way on is to run the ceremony over an empty database, which no text names. The birth half in createStandaloneStack has the same blind spot, so os migrate apply first does not help. Fix: have the remote face's initObjects record created versus found (it already asks hasTable) so getSchemaSyncStats answers on that face, and pin a guard case over a remote-face driver that a fresh remote store gets its birth marker and boots; or, if the maintainer rules the remote face out of R1, say so in the refusal's absent arm and in cli.mdx with the ceremony-on-empty remedy. The first keeps R1 as ruled.
  • B2. The landing tree must say the ceremony is open everywhere, and this head cannot: it predates two merges it must take. (a) fix(spec): 18.0 upgrade-guide entries say 18.0 supports fresh installs only #24661 (a78914818, not an ancestor of 02321084) put "18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path" into seven packages/spec/src/migrations/entries/semantic/ entries that compile into the upgrade guide; once this lands they are false, and the dev's report commits to restating them at the next merge. (b) feat(objectql,cli,plugin-webhooks)!: 18.x step 4: the walled lift (8b walled half, the os serve lift, the declared-webhook name guard, declared webhooks deployment-wide) #24667 (fdfc84238), which lands first, keeps "Until then the ceremony refuses … Keep a v17 database on a 17.x release" in content/docs/deployment/cli.mdx (204-209, 1202, 1482-1486), and a trial merge of the two heads conflicts on that file. Fix: in the main merge that the claim already requires, restate the seven entries to the ceremony's two steps (plan, then the reviewed apply with the backup acknowledged, no release line to stay on), resolve cli.mdx with this PR's paragraphs plus step 4's "every tenancy posture" clause, and pin the entries (a spec test that no semantic entry says "fresh installs only" or "17.x release"); then re-run the harness on the merged tree and re-review the new head.

…e and find, so a fresh libsql:// install gets its birth marker and boots

The contract review (B1) measured an R1 regression: the remote face answered
getSchemaSyncStats {0, 0} ("cannot say") by contract, so the boot guard never
saw a fresh remote store as created from empty and refused a fresh install as
"an earlier release created it". The transport's two schema doors already
probe sqlite_master per table; they now count created vs found, as
SqlDriver.initObjects counts from hasTable, and the remote face answers with
that count. Pinned on a real kernel over the transport's file double: a fresh
remote database boots on its birth marker, and an 18.0-shaped one is refused.

Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Oct 19, 2026
… helper (upgrade-harness/ceremony.ts)

Every suite that boots the 18.0 fixture under the ownership boot guard needs
it: the guard refuses an 18.0 database until the ceremony has certified it.

Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V
Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Oct 20, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:system size/xl tests tooling

Projects

None yet

2 participants