Skip to content

fix(cli)!: os migrate apply --allow-destructive holds back every organization_id drop until the organization-ownership ceremony verifies (#23684) - #23699

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23684-drop-gate-host-tables
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23684-drop-gate-host-tables

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23684
Clause-②: no (narrowing)

What changed

os migrate apply --allow-destructive now holds back every organization_id drop that the schema-drift report offers, until the organization-ownership ceremony's marker is verified. The marker is the sys_migration row adr-0131-organization-ownership.

  • The gate is packages/cli/src/utils/organization-ownership-drop-gate.ts. It held back only the tables in the platform inventory (CEREMONY_TABLES), so an organization_id drop on a host-declared platform-global object (ADR-0131 D7) was applied ahead of the ceremony. Now the gate holds back every one.
  • Its header now states which tables are covered and why.
  • apply.ts is unchanged. The call site and its refusal are reused as they are: the text, the --json envelope refused_organization_ownership_unmigrated, exit 1, and the hold on the whole run.
  • No new flag, no packages/spec change, and no second copy of the ceremony's inventory composition.

The contract text that already states this refusal:

  • The gate's header: the drop "waits for the ceremony's verified marker … ⛔ No flag stands it down".
  • ADR-0131 D10 item 5: a v18 runtime refuses to boot against a database the ceremony has not completed, with "no escape hatch that silently skips the check".

Hypothesis (a): every organization_id drop is the ceremony's table set (measured, holds)

  • The ceremony's plan meets every physical base table that carries organization_id. The table loop is in organization-ownership-plan.ts, from :1146 on c9810dd9d. Each such table gets one of four answers:

    • its inventory row;
    • its --fate-supplement row;
    • APPLICATION_OBJECT_FATE, for any other table;
    • the uninventoried-platform-table refusal, for a platform-prefixed table with no row.

    A table without the column is counted in outsideCeremony and skipped.

  • The drift can offer to drop only a column that is physically there. So on the primary database, every organization_id drop it proposes is on a table the ceremony plans. There, no v18 drift proposes an organization_id drop on a table the ceremony does not own, whether an app object or a cloud object (cloud objects are inventory placeholder rows or supplement rows). On the primary database, the rule does not over-refuse.

  • The ceremony drops a column only through the drift. dropColumnsThroughDrift in organization-ownership-column-drops.ts (:38) keeps only the drop_column entries the drift proposed. So every organization_id drop the ceremony could ever perform is one this rule holds back. That includes a fate-supplement object, which apply cannot read.

  • The v18 boot refusal judges the same population. findV17ShapeTables (packages/runtime/src/organization-ownership-boot-guard.ts:139) checks every registered object with systemFields.tenant: false and no declared organization_id whose table still carries the column; the inventory plays no part. A drop ahead of the marker on any of those tables hides the table from that refusal. That is the escape hatch D10 item 5 forbids.

  • Where the rule is stricter than the inventory and the declarations:

    • An application object that authors systemFields: { tenant: false } over a table that still carries the column. The ceremony plans that table with the application-object fate, and the boot refusal refuses over it too.
    • The telemetry sibling datasource. apply reconciles it, but the ceremony's plan does not read it.
  • Corpus reading on c9810dd9d. The tenant opt-out pattern (a tenant value of false, or systemFields set to false) has 0 hits in examples/**/*.ts, which holds 26 object files. The control is the same pattern over packages/**/*.object.ts: 14 files. So no example app object is held by the stricter half.

Hypothesis (b): the composed set is not built

The composed set would be the inventory plus the org-scoping declaration, read at the call site. It would not cover the fate supplement:

  • The supplement is a flag of the ceremony (--fate-supplement FILE), and apply never sees it.
  • A supplement object whose own metadata opts out of the column, rather than the deployment's declaration, has no other reading at apply.
  • Covering it would need a new apply flag.

Rule (a) covers such an object with no flag: the drift offers its drop exactly when the ceremony itself could drop it. So (b) is not needed, and this PR returns no needs_decision.

Hypothesis (c): the existing fixture

The pins extend PR #23681's fixture file in place (packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts), with no new rig. They reuse its host composition and its v17 template database:

  • the host's own lazy engine;
  • an org-scoping provider that declares sys_osgc_ledger and sys_osgc_held platform-global;
  • three host objects.

The new describe block runs the real os migrate apply --allow-destructive --yes --json three times:

  1. With no marker: refused, naming the ceremony, and nothing is dropped.
  2. CONTROL: with a verified marker, the drop applies.
  3. CONTROL: a destructive drop of another column is unchanged.

Before / after (fixture; table × marker state)

column marker before (base c9810dd9d) after (this PR)
sys_osgc_ledger.organization_id (host-declared platform-global) absent applied, exit 0 held, exit 1, refused_organization_ownership_unmigrated
sys_osgc_held.organization_id (host-declared platform-global) absent applied, exit 0 held (same refusal; tables: [sys_osgc_held, sys_osgc_ledger])
both of the above verified applied applied
sys_osgc_audit.organization_id (not declared) any not proposed, kept not proposed, kept
sys_osgc_ledger.legacy_23684_note (another column; no organization_id drop in the run) absent applied applied
sys_platform_setting.organization_id (platform inventory; existing pins) absent or unverified held held
sys_platform_setting.organization_id verified applied applied

Tests

The tests ran on cf15feedf4. The head 9b3d9d8daf adds only the changeset file on top of it.

  • Red leg (base c9810dd9d plus the pin, fix absent): Tests 1 failed | 2 passed | 3 skipped (6), with AssertionError: {"appliedOrganizationDrops":["sys_osgc_held","sys_osgc_ledger"]}: expected +0 to be 1. The two controls were green.
  • Green leg: pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 over the fixture file and src/commands/migrate/apply-organization-ownership-gate.integration.test.ts gave Test Files 2 passed (2), Tests 11 passed (11).
  • Ablation: node scripts/ablation-replace.mjs put the table set back to the platform inventory only, with a restore trap.
    • On-disk proof: anchor x1 to x0, replacement x0 to x1, blob 8d8ebfc14096 to bea1fc2829b0, marker grep -c 0 / 1 / 0.
    • Result: the first pin went red with the same message, while the 2 new controls and the 5 existing gate cases stayed green (Tests 1 failed | 7 passed | 3 skipped (11)).
    • Restore: blob == HEAD 8d8ebfc14096, and git diff HEAD was empty.
    • No dist step was needed. The tested gate is reached through relative src imports: the test, then ../commands/migrate/apply.js, then ../../utils/organization-ownership-drop-gate.js.
  • Typecheck: pnpm --filter @objectstack/cli typecheck exited 0. tsconfig.json includes src, where the pin lives, and check:test-typecheck answered OK.
  • Unit tier: --project unit gave 290 files passed. Two files answered PREREQUISITE ("packages/cli is not built"); after the CLI was built they passed, 29 tests. In total, 292 of 292 files and 4325 passed + 29 skipped.
  • Integration tier: only the two files above ran locally. The rest of the tier is declared to CI.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 64 commands at 9b3d9d8daf. On that head, in one pass over the built workspace, all 64 exited 0. Exit codes were captured before any pipe.
    • An earlier pass on cf15feedf4, before the changeset commit, was missing the workspace build. There, four commands (check:dual-build-cjs-loads, check:i18n, check:i18n-coverage, check:i18n-walk-parity) exited 3, PREREQUISITE NOT MET. That is not a measurement, and it was superseded by the pass above.
  • --ran on 9b3d9d8daf: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.
  • pnpm lint (eslint . --no-inline-config) on 9b3d9d8daf: exit 0, no output.
    • Control: the same eslint with --format json over the two changed .ts files linted 2 files, with 0 errors and 0 warnings.
  • CI on this PR is not read here. Its check-runs are the record for every family CI runs.

Acceptance notes

  • Docs (not edited; outside this card's file surface). content/docs/deployment/cli.mdx (:1000-:1006) says "organization_id on any table the organization-ownership ceremony covers waits for a verified … run. These tables are the ADR-0131 D7 tables and the sys_metadata family." The first sentence is still exact. The second names the platform's set and not, for example, an application object that opts out of the column.
  • The pending changeset .changeset/23429-v18-boot-refusal.md (not edited; outside this card's file surface). It describes this gate as refusing "any table in the ceremony's inventory". It also says "Only an os migrate apply --allow-destructive run that would drop an inventory table's organization_id is refused". Both changesets compile into the same release, and this one's FROM/TO supersedes those two sentences. They are reported for the seat to amend before release.
  • The refusal sentence in apply.ts (:515) reads "…, which the organization-ownership ceremony owns that drop, and it has not verified …". That is a grammar slip in an existing runtime string, reused unchanged as the ruling asked. Carrier: none.

Generated by Claude Code

…d platform-global object (red on base)

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
…l the organization-ownership ceremony verifies

The drop gate's table set was the platform inventory only, so
--allow-destructive dropped organization_id from host-declared
platform-global objects (ADR-0131 D7) and fate-supplement objects
ahead of the ceremony's verified marker. The ceremony plans every
table that physically carries the column, and the drift can only offer
a column that is physically there, so the gate now holds back every
organization_id drop the drift offers (ADR-0131 D10 item 5).

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
…rop until the ceremony verifies

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3bc407d3072565e620711d8ad2a715c8922e2610 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 58d64ec713c7acbee42f5565f44f9e65ab01cb2c — the merge of head 9b3d9d8daf6d5f4507db839446306bbcc8e2d9ab into base 3bc407d3072565e620711d8ad2a715c8922e2610, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 58d64ec713c7acbee42f5565f44f9e65ab01cb2c && git checkout 58d64ec713c7acbee42f5565f44f9e65ab01cb2c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3bc407d3072565e620711d8ad2a715c8922e2610 9b3d9d8daf6d5f4507db839446306bbcc8e2d9ab && git checkout -B drift-repro 3bc407d3072565e620711d8ad2a715c8922e2610 && git merge --no-ff 9b3d9d8daf6d5f4507db839446306bbcc8e2d9ab

node scripts/docs-audit/affected-docs.mjs --json 3bc407d3072565e620711d8ad2a715c8922e2610

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 9b3d9d8daf6d5f4507db839446306bbcc8e2d9ab
Local-runs: none

The review is owed by the narrowing (Clause-②: no (narrowing), a BREAKING minor). The face is read off the PR's file list: record-recognisers.mjs --template --paths prints TIER_DEFAULT, with no packages/spec/src/** path and no governed path (check-governed-merges --pr 23699: NOT governed, 0 of 3 paths). So this seat writes it itself.

It rests on:

Nothing was built or run.

① Derived judgments

  • Accept set: one CLI invocation narrows; nothing widens. Right.
    • ceremonyOrganizationColumnDrops no longer filters by the platform inventory (CEREMONY_TABLES and its import are gone). Every organization_id drop_column the drift offers is held back until the ceremony's marker is verified.
    • apply.ts is unchanged: the call site, the refusal text, the --json envelope refused_organization_ownership_unmigrated, exit 1 and the whole-run hold are reused.
  • Why "every drop the drift offers" is the ceremony's own set, read on main:
    • The ceremony's plan meets every physical table that carries organization_id (an inventory row, a fate-supplement row, the application-object fate, or a refusal by name), and the drift offers only a physically present column.
    • The v18 boot guard (packages/runtime/src/organization-ownership-boot-guard.ts) refuses over the same population: an object with no declared organization column whose table still carries one. It states that a fresh database is never refused, because no table it creates carries the retired column.
    • So the gate refuses exactly what the boot would refuse. The two rules agree, and a fresh database is unaffected.
    • It reads the drift, so there is no second copy of the inventory composition, no fate-supplement read, and no new flag. These are the three bounds of the dispatch.
  • Stricter than the inventory plus declarations, in two places the dev named:
    • an application object authoring systemFields: { tenant: false } over a table that still carries the column;
    • the telemetry sibling datasource.
    • Both are judged the same way by the boot guard, and the corpus has 0 application objects of that shape in examples/.
  • Pins (PR fix(cli): os migrate organization-ownership --apply drops organization_id from an object the host declares platform-global (ADR-0131 D7) #23681's fixture, a second describe; the real os migrate apply --allow-destructive --yes --json on a v17 template with a host declaring two platform-global objects):
    • held back with no marker: exit 1, the refusal naming the ceremony, and both tables keep the column;
    • CONTROL: with the marker verified, the drop applies;
    • CONTROL: another column's destructive drop applies unchanged.
    • The red leg on base reproduced the card. The ablation (the table set put back to the inventory) turned pin 1 red and kept the controls and the 5 existing gate cases green. The restore was proven against HEAD.

② Semver level

  • @objectstack/cli minor, fix(cli)!: with a BREAKING line: an accept-set narrowing under the launch-window convention, as os migrate organization-ownership has no SQLite occupancy gate: run beside a process holding the file open, a later write by that process was lost (WAL and SHM deleted under it) #23400's gate shipped. Right.
  • Clause-②: no (narrowing) stands: the changeset cites the contract text that already states this refusal (the gate's header, "⛔ No flag stands it down", and ADR-0131 D10 item 5).
  • ADR-0087: not-required (no-migration-prescription). Right: no spec key, stored shape or export moves.
  • Changeset prose, sentence by sentence:
    • FROM is the measured exit 0 with both drops applied.
    • TO is the reused refusal (text, envelope, exit and whole-run hold), checked against apply.ts on main.
    • "Unchanged" (marker verified, other columns, runs without the flag, no new flag) matches the pins and the diff.
    • The remedy names --fate-supplement for host objects.

③ Boundary flags

Implemented-by: claude/issue-23684-drop-gate-host-tables
Reviewed-by: session_01Q9PZ5Pzqcus4woVvTZwWNU

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

✅ ACCEPT: PR #23699 at 9b3d9d8daf (#23684), round 0. Every check on this head is green or a roster skip. The TIER_DEFAULT record is 6197462406 (PASS) · 2026-10-15T16:51Z

domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU · holder of claim 6195305019. Report: the os-dev-report on #23684.

Checklist, read against GitHub and origin/main, not against the report:

  • PR shape: draft, base main, title fix(cli)!:, assignee sunhaolin. Line 1 is Fixes #23684, the body's only closing keyword. Line 2 is Clause-②: no (narrowing).
  • Scope: 3 files, +193 / −10, inside the claim's surface:
  • The fix, the pins and the semver level are judged in the record 6197462406. The gate now refuses what the v18 boot guard refuses, and a fresh database is unaffected.
  • Hypotheses:
  • Gates:
    • Local, at 9b3d9d8daf: 64 derived, 64 run, 0 unmeasured, all exit 0. pnpm lint exit 0. The two integration files: 11 passed. The CLI typecheck exits 0. The unit tier: 292 files, 4325 passed.
    • Gate evidence: the check-runs pinned to 9b3d9d8daf, read at this stamp. 40 distinct checks: 34 success, including Lint · ESLint, the Type Check family, all six Test Core shards, Build Core and Check Changeset, and 6 skipped. All 6 skips are roster names: Build Docs, Console Pin Gate, Dogfood Regression Gate (matrix), Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance. Nothing is running and nothing failed.

The dev's open question (amend .changeset/23429-v18-boot-refusal.md here?): no.

Accepted deviations:

  • The pins extend the fixture file rather than adding a new one.
  • The tests ran on cf15feedf4. The head adds only the changeset, and the gates were re-run in full on the head.
  • The model-free commit trailers, per AGENTS.md.

Out-of-scope, Acceptance notes, 「承接者:无」:

  • the cli.mdx sentence naming the platform's table set only;
  • the grammar slip in apply.ts:515's refusal.

Landing: pr_ready + automerge_enable through the relay, in this act. If #23685's driver-memory timeout ejects it, it is not re-queued blind.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 15, 2026 16:52
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 15, 2026 16:52
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit 6074eaf Oct 15, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23684-drop-gate-host-tables branch October 15, 2026 17:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants