Skip to content

feat(cli): os migrate organization-ownership — the ADR-0131 D10 inventory and the read-only ceremony plan (C7a) - #22643

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22617-c7a-inventory-plan
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22617-c7a-inventory-plan

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22617
Part of #15211
Clause-②: yes (widening)

The Clause-② widening is a new read-only command surface. No existing command or verdict changes.

What this adds (C7a of the ADR-0131 D10 ceremony)

  1. The inventory: packages/cli/src/utils/organization-ownership-inventory.ts. It gives each object one D10 fate (column drop, mirror deletion, attribution through a parent anchor, or report) with its citation. 128 objects:

    source objects column-drop mirror-deletion attribution report
    platform, first census (5479883784, at 00d8f6541b) 84 37 5 38 4
    platform, added since (gated census on main) 3 1 0 2 0
    examples (the census's "28" = 28 files declaring 33 objects) 33 0 0 31 2
    cloud supplement (cloud-carried) 8 0 0 0 8
    total 128 38 5 71 14
  2. os migrate organization-ownership: the read-only plan (D10 ceremony item 1). For each table it gives:

    • the fate and its citation;
    • the row counts (total, NULL organization, stamped) and what the fate touches;
    • the ruled categories;
    • the derivable rows per anchor;
    • the rows whose owner cannot be derived, listed by id with the reason;
    • notNull: { willReceive, reason }.

    It writes the plan to a file (--out, default organization-ownership-plan-TIMESTAMP.json) and never overwrites one. --json also prints it.

  3. The completion-marker design: below. It is design only, with no code.

Why os migrate organization-ownership and not os migrate --plan

Read-only, and refusal

  • Read-only boot. It uses the family's read-only boot (deferSchemaDdl + readOnlyProbe). It reads the physical database through the planned driver's raw seam, with SELECT statements only.
  • Refusals. It refuses the whole plan, names the table, and exits 1 with no file written when:
    • the dialect has no catalog statement;
    • the catalog or a table read fails;
    • a table lacks a column its fate reads;
    • a platform-prefixed table carries the column but has no inventory row;
    • a table name cannot be quoted;
    • the database holds none of the inventoried platform tables (the wrong --database-url).
  • Absent tables. An inventoried object with no table on this database is listed as physical: 'absent' with rows: null. It is never reported as a table of zero rows.
  • Rotation shards. sys_activity shards are folded onto the base object.
  • Measured on a real database. The family pin's served database (an os serve boot, then the next release's artifact) plans 20 tables: 9 column-drop, 11 attribution, and sys_activity folded from its shard.

Where it lives, measured

The inventory and the planner sit under packages/cli/src/utils/. oclif's pattern strategy turns every module under src/commands/** into a command, so a data module there would become one. The command is src/commands/migrate/organization-ownership.ts. The inventory is a typed TypeScript table rather than a JSON file, so the CLI ships it in dist and the planner and the pin read one spelling.

The completion marker — design only (C7b builds it)

⛔ This PR contains no code that writes or reads the marker. This section is the design that C7b's last step writes and the v18 boot refusal reads.

Where it lives. One row in sys_migration, the deployment-level migration-flag table (platform-objects/src/system/migration-flag.ts, #3617). The row's primary key is a new spec constant, ORGANIZATION_OWNERSHIP_MIGRATION_ID = 'adr-0131-organization-ownership', which sits beside FILE_REFERENCES_MIGRATION_ID and VALUE_SHAPES_MIGRATION_ID. No new table. sys_migration is itself a D7 column-drop object. Its drop runs inside the ceremony before the marker is written, so the marker is always written to the table's final, tenant-less shape.

What it records. It reuses the existing columns:

  • applied_at: when the apply finished.

  • verified_at: when the post-check passed.

  • blocking: the number of tables whose post-check did not complete. This is not the count of reported rows. Reported rows are D10 fate 4: they are named at boot and do not block it.

  • details: one JSON document:

    • ceremonyVersion: the integer 1, the same ceremonyVersion this plan prints;
    • inventoryDigest: the sha256 the plan prints, which ties the marker to the inventory it executed;
    • posture;
    • tables: per table, { object, fate, remainingNull, notNull: 'applied' | 'withheld', reason }.

    verified_at is set only by a post-check that re-ran the plan and found zero tables left mid-fate.

When it is written. Only as the ceremony's last statement, after the post-check. An interrupted apply leaves no marker; its checkpoint lives in the ADR-0119 journal (os migrate resume). A fresh v18 database gets the marker when it is created, the way attestFreshDatastore attests the creation-attested migration ids. A database born on v18 has nothing to migrate.

How a v18 boot reads it. This has the same fail-fast shape as ADR-0093 D5, with no escape hatch.

  • When: before schema sync and before any plugin start(). Additive sync could otherwise create tables on a database the boot is about to refuse.
  • How: a primary-key read of that one row, through the raw read seam, in the same pre-boot gate as the tenancy-posture refusal.
  • What it decides:
    • A database with no ObjectStack tables is fresh, and is attested.
    • A database that holds sys_organization but no marker row is refused, and the refusal names os migrate organization-ownership --apply.
    • A marker that is unreadable, malformed, at a ceremonyVersion below the runtime's required version, missing verified_at, or with blocking > 0 is refused.
  • Reads fail toward refused: the same asymmetry as readDataMigrationFlag.
  • The refusal text says what was found, that the server is refusing to start, the command to run, and that a 17.x runtime is the way to keep 17.x semantics.
  • ⛔ There is no OS_ALLOW_* / OS_SKIP_* variable and no flag that skips the read (ADR-0131 D10 item 5). A marker with tables still reporting NULL rows boots. Those tables are listed at boot with counts and the remedy (fate 4), and they stay unconstrained.

Verification (head a3f7ab26)

  • Unit tier. pnpm --filter @objectstack/cli typecheck && pnpm --filter @objectstack/cli exec vitest run --project unit → Test Files 278 passed (278), Tests 4115 passed (4115), VERDICT command-exit 0. This includes the new organization-ownership-inventory.test.ts, the enumeration pin:
  • Integration tier, the files this diff touches. vitest run --project integration src/utils/organization-ownership-plan.integration.test.ts src/utils/schema-migrate.one-shot-family.integration.test.ts -t 'organization-ownership|ADR-0131|missing from CALLERS' → 14 passed. That covers:
    • the per-table plan against a fixture carrying each fate, under isolated and under single;
    • six refusals, each naming its table (uninventoried platform table, failing read, missing column, unsupported driver, not an ObjectStack database, unquotable name);
    • the control: across two runs the database file's sha256 and a full schema-and-row dump are byte-equal, and every statement issued starts with SELECT;
    • the family pin's no-write cases, now including this command (the database stays byte-identical and no missing SQLite file is created).
  • Ablation. Through scripts/ablation-replace.mjs, if (hasPlatformObjectPrefix(base)) { was changed to … && false) {. The mutation landed (anchor 1→0) and the uninventoried-table pin went red (1 failed, 9 passed). The restore was verified: blob equals HEAD and git diff HEAD is empty.
  • Gates. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives 65 commands. All 65 exit 0 on a3f7ab26, and --ran reconciles them: 65 derived, 65 run, 0 NOT-MEASURED, all with exit codes.
    • Before the fix commit, two were real findings on this diff and are fixed: check:doc-authoring (tracker numbers in citation strings, now ADR sections and ruling-record ids) and check:dispatcher-error-vocabulary (an unregistered PLAN_REFUSED code, dropped because the refusal carries its reason).
    • Three were unmet prerequisites (a shallow clone, unbuilt packages); after the clone was deepened and the packages built, all three ran green.
  • Lint. eslint --no-inline-config on the changed files exits 0. The repo-wide lint is CI's.

Acceptance notes

  • The second census. measure: census the dependents of the SQL driver's orWhereNull tenant-wall carve-out before deciding its future (NULL org_id rows are globally visible on shared-DB walled deployments) #13564's 2026-09-02 ledger (5507087600) answers 404, and the 2026-09-03 cloud-side supplement lives in cloud, which this session cannot reach. Both are NOT MEASURED. The cloud rows are the names this repository records as cloud-provided. Cloud stays on v17 by ruling 6094175435, so they carry fate 4 and cloudCarried, and C10 assigns them their fates.
  • Parent chains are one level. A child whose parent is NULL now but attributable in the same apply is listed as unattributable, with the reason parent row … has no organization. C7b's apply runs attribution parents-first and its post-check re-plans. Under single, the Default Organization covers those rows anyway.
  • sys_notification_template stays on attribution. It is the conservative fate: no column tells a seeded row from an authored one, so no row is selected as a mirror on a guess.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AB6Nvx7Ue6yzJypw7VTvkj


Generated by Claude Code

…only plan, its inventory and pins

Claude-Session: https://claude.ai/code/session_01AB6Nvx7Ue6yzJypw7VTvkj
Co-authored-by: Claude <noreply@anthropic.com>
…ed without the auth family

Claude-Session: https://claude.ai/code/session_01AB6Nvx7Ue6yzJypw7VTvkj
Co-authored-by: Claude <noreply@anthropic.com>
…ot tracker numbers; the plan refusal carries its reason, not an unregistered code

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 236 documentable anchor(s).

93 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 1b99388505d33c940757e85685ca925d6cd57f04.

⛔ 16 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 33 pages), sys_user (literal, 39 pages)
  • 42 name(s) were too generic to anchor anything (single lowercase words)
  • 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 1b99388505d33c940757e85685ca925d6cd57f04 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 99fb429a23dbf7f3df833df218fc3b32b7eb347f — the merge of head a3f7ab26ad07cc5d5b087e58ef9d4ade1c8ffbe7 into base 1b99388505d33c940757e85685ca925d6cd57f04, 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 99fb429a23dbf7f3df833df218fc3b32b7eb347f && git checkout 99fb429a23dbf7f3df833df218fc3b32b7eb347f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1b99388505d33c940757e85685ca925d6cd57f04 a3f7ab26ad07cc5d5b087e58ef9d4ade1c8ffbe7 && git checkout -B drift-repro 1b99388505d33c940757e85685ca925d6cd57f04 && git merge --no-ff a3f7ab26ad07cc5d5b087e58ef9d4ade1c8ffbe7

node scripts/docs-audit/affected-docs.mjs --json 1b99388505d33c940757e85685ca925d6cd57f04

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 1b99388505d33c940757e85685ca925d6cd57f04 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a3f7ab26ad07cc5d5b087e58ef9d4ade1c8ffbe7
Local-runs: none

PR #22643 (card #22617, C7a of #15211) · net diff against main: 6 new files, 1 edited test pin, 2,011 added lines, 0 deleted; no existing command, export or runtime path changes. Inputs: the card body and both comments (claim 6094844899, report 6095493258), ruling 6094175435 and the seven pointers the card names, ADR-0131 D10 and ADR-0093 D5, the PR body, file list and diff, and the check-runs on the head, read at 2026-10-10T08:20Z.

① Derived judgments

Public-surface and accept-set changes the diff implies — each named right or wrong:

  1. New command os migrate organization-ownership (packages/cli/src/commands/migrate/organization-ownership.ts) with flags --database-url (env OS_DATABASE_URL, the family's spelling), --out, --json. Right. Bare os migrate (the schema-drift plan) is untouched; the card's os migrate --plan wording is the ADR's name for the read-only MODE, and in this family that mode is a named subcommand's bare run — the dev's reading is the family's convention and is stated in the PR body.
  2. Read-only accept set. Boot through bootSchemaStack({ deferSchemaDdl: true, readOnlyProbe: true }); the planner issues SELECT statements only through one PlanReader seam; the one write is the plan FILE, opened with wx so a kept plan is never overwritten; default name organization-ownership-plan-TIMESTAMP.json. Right against D10 ceremony item 1 ("written to a file the operator keeps; nothing is changed"). The one-shot family pin (schema-migrate.one-shot-family.integration.test.ts) now drives the command under noWrite with write: [] and the note the pin's own rule demands for an empty list. Right.
  3. Refusal set — exit 1, no file, the table named: driver-unsupported, catalog-unreadable, not-an-objectstack-database, uninventoried-platform-table, unreadable-table (a failed read, a count with no number, a table the catalog lists but gives no column, rotation shards that disagree on the column), missing-column, unsafe-identifier. Right against D10's "a plan that cannot enumerate a table refuses instead of reporting it as empty", and kept distinct from the family's "empty work with no database" answer that pointer 6018712820 warned about: the wrong --database-url case refuses as not-an-objectstack-database instead of answering empty.
  4. An inventoried object with no table is listed as physical: 'absent', rows: null, tables: []. Right (③ Q1): the catalog answered, and null is not zero — nothing is reported as empty.
  5. JSON surface. Success { file, plan }; refusals { error: 'plan_refused' | 'boot_failed', reason, table?, detail }. Right. boot_failed is the family's existing spelling (account-issuer, duplicates, unmapped-columns); plan_refused is carried on error, not thrown as a code, so no dispatcher-vocabulary registration is owed — the dev dropped the PLAN_REFUSED class field for that reason, and Lint & Repo Gates is the verdict on it.
  6. Plan document shape: kind, ceremony: 'ADR-0131 D10', ceremonyVersion: 1, readOnly: true, inventoryDigest (sha256 over the inventory), defaultOrganization, organizations, per table fate / citation / source / physical / tables / organizationColumn / rows{total, organizationNull, stamped} / fateCounts / departures / categories / attribution / unattributable[{id, reason, shard?}] / notNull{willReceive, reason}, and a summary with byFate, notNull.willReceive / willNotReceive, outsideCeremony. Right: it answers all four of D10 item 1's asks (fate, the counts the fate touches, the unattributable ids, NOT NULL per table), and ceremonyVersion + inventoryDigest are the two handles the marker design ties to.
  7. The inventory (organization-ownership-inventory.ts, data: 128 rows, one fate and one citation each; the unit pin holds it against both censuses as counted, the live PLATFORM_OBJECTS_BY_PACKAGE, and CLOUD_PROVIDED_OBJECT_NAMES):
    • Fate 1 on the sys_metadata family (pointer 6068052798), the D7 plumbing (sys_job, sys_job_run, sys_job_queue, sys_flow_dispatch, sys_migration, sys_migration_journal), sys_audit_log, sys_metadata_activation, sys_presence, sys_platform_setting. Right — D7 names each.
    • Fate 1 "nothing to drop" on the better-auth tables that never carried the column and on sys_api_key. Acceptable reading: D10 gives every object one fate; the plan reads the physical column to tell "nothing to drop" from "drop pending". Not wrong.
    • Fate 2 on the catalog (sys_position, sys_permission_set, sys_capability, sys_position_permission_set) with the managed_by in (package, platform, system) mirror selector and the authored rows counted as a category that is never deleted. Right (D3/D13; D10 fate 2 is "re-derivable from code and nothing else").
    • Fate 2 on sys_email_template after the promotion, with the customized: true and organization-stamped populations counted. Right per §6 Q1 ruled C (pointer 6020279837, record 6020178017).
    • Fate 3 on the first-named C7 members with the anchors the backfills already use (sys_file subject then sys_attachment holder; approvals subject/parent; sys_automation_run trigger record; sys_record_share record), plus sys_setting's pre-stamp tenant/user rows (pointer 6061638897: Default Organization under single, reported under a wall, never hidden) with the global rung as a moved departure (pointer 6051723395). Right. sys_business_unit_member → business_unit_id → sys_business_unit, and sys_business_unit → parent_business_unit_id — the card's two read-ins. Right.
    • Fate 4 report + cloudCarried on the eight CLOUD_PROVIDED_OBJECT_NAMES. Right under ruling 6094175435 (cloud stays on v17; no fate is guessed; C10 assigns them).
    • sys_metadata categories: the five-type presentational promotion, the conflict list grouped by (type, name) over distinct organizations, non-overridable org-scoped rows reported, and environment-overlay duplicates grouped by (type, name, package_id). Right per 6020279837 / 6071418113. Pointer 6081248392 asks for view overlays carrying a public form; the category counts every org-scoped view row, a superset — acceptable for the plan (no column in sys_metadata alone selects the finer set); C7b's promotion step owns the finer cut.
    • Application-object default (a non-platform table carrying the column with no row): fate 3, empty anchors → Default Organization under single, reported under a wall. Right by D10 fate 3's letter. Boundary for C7b: the apply must confine itself to tables the registry declares or confirm each unknown table before writing; planning it is fine.
    • sys_sso_provider fate 4 with notNullExempt — see ③ Q2.
    • Parent chains resolve one level (a child whose parent is attributable in the same apply is listed as unattributable, reason given). Acceptable for a plan; the dev names it in the PR body; C7b's apply runs parents-first and its post-check re-plans.
  8. The completion-marker design (prose in the PR body and the report's marker_design; the diff holds no code that reads or writes it): one sys_migration row keyed adr-0131-organization-ownership beside the existing migration ids; written only as the ceremony's last statement after the post-check; verified_at only on a zero-remaining post-check; blocking counts tables whose post-check did not complete, never fate-4 residue; details { ceremonyVersion, inventoryDigest, posture, tables }; the v18 boot reads it before schema sync and before any plugin start, by primary key through the raw read seam, reads fail toward refused, the refusal names os migrate organization-ownership --apply and the 17.x runtime as the way to keep 17.x semantics; a fresh database is attested as the creation-attested ids are; no OS_ALLOW_* / OS_SKIP_* variable and no flag that skips the read. Right against D10 item 5 and the ADR-0093 D5 fail-fast shape MINUS D5's own escape hatch, which D10 item 5 and the card both forbid. Order: sys_migration is itself a D7 column-drop object and its drop runs before the marker write — the sequencing point 6018712820 raised. Right. "A marker with tables still reporting NULL rows boots, listed with counts and remedy" — consistent with D10 fate 4; hold C7b to blocking counting only incomplete post-checks.

Governance: the file list touches no governed path (.claude/**, docs/adr/**, skills/**, AGENTS.md, CLAUDE.md), so this is not a Prime Directive #14 landing; the record is owed on the claim's Clause-②: yes (widening). Governed Surface Queue Guard is green on the head, consistent with that.

② Semver level

  • .changeset/22617-cli-organization-ownership-plan.md: '@objectstack/cli': minor. Right. The diff publishes one new command in a released package and removes or renames nothing; minor is the floor Clause-②: yes demands and nothing here is breaking, so no ADR-0087 disposition is owed. Only @objectstack/cli is touched. src/utils/* is not on the package's exports map (., ./console, ./hook-body), so the two new util modules are internal; the published surface is the command and its JSON.
  • Clause-②: Clause-②: yes (widening) at line start in the PR body (line 3) and in the changeset body; the arm is from the closed pair and matches a new read-only command surface. Check Changeset is green on the head. The dev's deviation 1 (the dispatch asked for Clause-②: yes; the dev wrote the (widening) arm) is the truthful declaration — right.

③ Boundary flags

Dev deviations (report 6095493258):

  • 1 — the (widening) arm: answered in ②, right.
  • 2 — a physically absent table listed as absent rather than refused as 'missing': answered as Q1 below.
  • 3 — worktree method (branch checked out into the worktree after detaching the primary, fast-forwarded to origin/main before any edit): process, no effect on the diff; noted.

open_questions:

  • Q1 — absent table: A (list as absent, rows: null). ANSWERED: A is right. D10 refuses a table the plan "cannot enumerate"; a table the catalog says is not there has been enumerated, and the forbidden outcome — reporting it as empty — is avoided by physical: 'absent' with rows: null. B would refuse forever on every deployment that does not compose every plugin (no sys_approval_* tables, for one), and the wrong-database case B guards against is refused separately as not-an-objectstack-database.
  • Q2 — sys_sso_provider: A (fate 4 report with notNullExempt). ANSWERED for C7a, ESCALATED for C7b. For the read-only plan, listing is the D10-conservative posture — nothing attributed, nothing constrained, nothing guessed — and is right. But notNullExempt names a permanent outcome D10 does not have (fate 4's constraint lands "only when it reports zero NULL rows"; it never says "never"), and D1 says every object carrying organization_id carries it NOT NULL once D10 has cleared. Whether better-auth's provider-to-organization relation under tenancy.enabled: false is a D1 exception, or follows D7's audit-log pattern (a plain attribution field under a name the tenant-field resolver does not claim), is a ruling, not a dev's or a reviewer's call. Escalation: the seat posts this as a C7b / C8 question on feat(objectql,cli): inventory + migration — four fates per object, mirrors deleted only after the id→name rewrite is verified, per-table boot report (ADR-0131 D10) #15211; C7b applies no notNullExempt before it is ruled.
  • Q3 — sys_notification_template: A (attribution, never deleted). ANSWERED: A is right. D10 fate 2 sanctions deleting only rows provably re-derivable from code; no column marks a seeded row, so B would delete authored rows — exactly what D10 forbids ("never guessed, never deleted"). §6 Q1 (C) retires the seed; it does not sanction deleting existing rows.

Items the owning seat acts on (none is a FAIL ground for this diff):

Check-runs on the head (required contexts per the main ruleset, each read as its conclusion):

  • Required: TypeScript Type Check — success (114169557068); Test Core — success (114170610664; shards 1/6–6/6 all success); Dogfood Regression Gate — success (114169319054; shards 1/3–3/3 all success); Build Core — success (114168017358); Temporal Conformance (live PG + MySQL) — success (114168017299); Lint & Repo Gates — success (114167956582); Governed Surface Queue Guard — success (114167956364).
  • Also green: Check Changeset, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Dogfood Verify CLI, Type Check · workspace / source gates / consumer gates / debt ledger, Auto Label, filter, and the four card/branch invariants (claim-this-branch, same-issue, same-single-writer-path, Part-of must not close).
  • Skipped, none required: Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in). No check-run on the head is red; none is pending.
  • The dev's 65 derived gate families all exit 0 on this head per report 6095493258; two earlier reds (check:doc-authoring, check:dispatcher-error-vocabulary) were fixed before this head, and Lint & Repo Gates confirms it on the head.

Implemented-by: claude/issue-22617-c7a-inventory-plan
Reviewed-by: session_01Bw3y2DWhT9RPnrmDsNqEVG

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 10, 2026 08:24
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 10, 2026 08:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit cc305a3 Oct 10, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22617-c7a-inventory-plan branch October 10, 2026 08:42
github-merge-queue Bot pushed a commit that referenced this pull request Oct 10, 2026
…t scaffold with the workspace protocol (nightly tiers) (#22699)

Fixes #22622
Clause-②: no
Two nightly-tier CLI test files change and nothing else: no accepted
input, key, export or error code moves, and nothing publishes.

Two independent reds on the nightly tiers, both in `domain:cli`. **Both
are fixed inside the nightly tests**, following the seat's review
6099406666 on #22622. Nothing else changes:

- the family-roster check;
- the noise budget and every assertion in that file;
- the `create-objectstack` template, the version-time stamp
(`scripts/sync-template-versions.mjs`) and their tests, which are
byte-for-byte as `main` has them.

## (a) Both reds, reproduced before any fix

Base `9646991283`, `OS_TEST_TIERS=nightly`, `--project integration`,
through the verify lock: `Tests 2 failed | 51 passed (53)`.

- `json-stdout-purity.e2e.test.ts`, `is exactly the set listed here`:
`expected [ 'meta resync', …(17) ] to deeply equal [ 'meta resync',
…(15) ]`, with `+ "migrate organization-ownership"` and `+ "migrate
security-catalog-overlays"`. `migrate organization-ownership` came from
PR #22643, after the nightly that filed the card. The members were taken
from `discoverFamily()`'s output.
- `serve-boot-diagnostics-noise-budget.e2e.test.ts`, `highlights the
dead button once…`: `expected 0 to be greater than 0` at `:176`. The
boot printed `✗ package 'com.example.support-desk' targets protocol ^17
(engines.protocol) but this runtime is protocol 18.0.0. This is a
major-version break. Run: objectstack migrate meta --from 17`. That is a
refusal, so no *Boot diagnostics* block is printed at all.

After, on `3d63ed7db5`: `Test Files 2 passed (2)`, `Tests 59 passed
(59)`. That is 53, plus 3 cases for each of the two new members.

## Failure 1: the two new `--json` members are driven

`FAMILY` gains both members, each in its bare form. The CLI was first
run against the purity fixture to see each one's face:

- `migrate organization-ownership` shows its refusal face. Over a
database with none of the platform tables it prints
`{"error":"plan_refused","reason":"not-an-objectstack-database",…}` and
exits 1. The JSON is emitted after `stack.shutdown()`, and no plan file
is written.
- `migrate security-catalog-overlays` shows its read-only preview:
`listed: 0`, exit 0. There is no `sys_metadata` hydration and nothing is
deleted.

Both print all three boot diagnostics on stderr. The discovery and the
`toEqual` roster check are untouched.

## Failure 2: the noise-budget e2e aligns the pair it boots

### (b) What the version-time stamp writes

Measured read-only at `9646991283`:

- `changeset status --output` (`pre.json` is
`{"mode":"pre","tag":"next"}`) gives `@objectstack/spec major 17.7.0 ->
18.0.0-next.0` and `create-objectstack major 17.7.0 -> 18.0.0-next.0`
(the `fixed` group).
- `loadScaffolderVersion()` on `18.0.0-next.0` gives
`{"version":"18.0.0-next.0","major":"18","range":"^18.0.0"}`. So the
version pass stamps `engines: { protocol: '^18' }` into the released
template.

**Triage's "every project that v18's `create-objectstack` scaffolds
would boot with the protocol-gap warning" is false.** No released v18
scaffold carries the gap. PR #22215's changeset says the same ("the
template keeps `'^17'` until the version pass stamps it").

The red is `main`'s pre-mode window: packages at `17.7.0`,
`PROTOCOL_VERSION` at `18.0.0`. Only an in-repo pairing sees it: a
published-line scaffold booted on the workspace runtime with nothing
installed.

### Round 1 changed the product side, and CI falsified that

Round 1 (`bff640d98`) stamped the template's `engines.protocol` from
`PROTOCOL_VERSION`'s major. On that head, `Scaffold with repo dist`
(`scaffold-e2e.yml`, job 1) went red: "package 'com.example.e2e-app'
targets protocol ^18 (engines.protocol) but this runtime is protocol
17.0.0".

That gate guards the path a user takes. It scaffolds with the repo-built
scaffolder and installs `@objectstack/*` from the registry (`^17.0.0`,
so protocol 17). On that path a template's range must match the protocol
of the packages it installs, which is the package major. The sync script
and the `template-consistency` ratchet already enforce exactly that on
`main`.

Commit `60fa01c7a7` removes round 1's product-side change:

- `templates/blank/objectstack.config.ts`,
`template-consistency.test.ts`, `template-version-stamps.test.ts` and
`scripts/sync-template-versions.mjs` are back at their `main` blobs
(`6308f29e`, `d32c819c`, `48ba146e`, `f7704db8`, all equal at base and
`origin/main`);
- the `create-objectstack` changeset is deleted.

### This round: the setup aligns the pair it boots

This follows the seat's verification-strategy ruling in 6099406666.

After scaffolding, and before the fixture files are written,
`alignProtocolRange()` rewrites the scaffold's `engines.protocol` to
`'^' + PROTOCOL_MAJOR`. `PROTOCOL_MAJOR` is read from
`@objectstack/spec/kernel`, the workspace spec this file boots, the same
import other CLI e2e files use.

- **Only the major's digits are rewritten.** When the majors agree, the
scaffold is left byte-for-byte as written, and the helper does not write
the file at all.
- Measured on the template: at `'^17'` (the window) the hash moves
`684e38ed0d1a` → `5d6e6468255e`, and only the `engines` line differs.
- At `'^18'` (majors agree) the hash stays `5d6e6468255e` →
`5d6e6468255e`, byte-identical.
- **A missing stamp throws in `beforeAll`.** A template that stops
declaring the key fails this file loudly instead of skipping the
alignment.
- **Why a direct rewrite of the one key, and not `objectstack migrate
meta --from 17 --write`.** The `--write` command is the step the refusal
prescribes, and it was measured first. On a scaffold under
`packages/cli/node_modules/` it exited 0 with `not written
[outside-project]` and left the config unchanged. The codemod refuses
any file whose real path has a `node_modules` segment
(`authored-source-codemod.ts`, the `outside-project` refusal). This
project has to live under this package's `node_modules` so that its
imports resolve to workspace copies.
- **What stays the same:** the noise budget, all three assertions, and
the ticket object and actions. Once the version pass lands, the window
closes and the alignment becomes a no-op.

## Ablations

Both ran through `scripts/ablation-replace.mjs` in wrap mode on
committed trees, under the verify lock. Each restore was proven by blob
equal to HEAD and an empty `git diff HEAD`. Neither subject resolves
through `dist/`: the FAMILY and the setup live in the test files.

| Mutation | Anchor | Result |
|---|---|---|
| `alignProtocolRange(join(dir, 'objectstack.config.ts'));` deleted from
the setup | x1 → x0, blob `058fbecec7dc` → `bfedb0522aa5` | noise-budget
red with the original refusal: `✗ package 'com.example.support-desk'
targets protocol ^17 … this runtime is protocol 18.0.0 …`, `expected 0
to be greater than 0`, 1 failed and 2 passed. After the restore to blob
`058fbecec7dc`: 3 passed. |
| `'migrate security-catalog-overlays': [],` deleted from `FAMILY`
(round 1, same blob as now) | x1 → x0, blob `698034ba9bcb` →
`5dfe8780897a` | roster pin red: `expected [ 'meta resync', …(17) ] to
deeply equal [ 'meta resync', …(16) ]`, `+ "migrate
security-catalog-overlays"` |

## Tests (on `3d63ed7db5`)

- **Nightly tier** (`OS_TEST_TIERS=nightly`, `--project integration`,
verify lock): the two card files, 59 passed.
- **`create-objectstack`:** suite 17 files and 254 tests passed. `pnpm
check:template-version-sync` is green with 40 assertions, the same count
as on `main`. Both are unchanged, because this diff does not touch the
package or the script.
- **`@objectstack/cli`:**
  - unit layer: 279 files and 4126 tests passed;
- `typecheck` (`tsc --noEmit` plus `check:test-typecheck`) is OK, and
`tsconfig.test.json`'s `--listFiles` includes both edited files.
- **Changeset:** `node scripts/check-empty-changeset.mjs --base
origin/main` exits 0. It reports no empty-frontmatter changeset added
and no merge-base changeset modified or deleted.
- **Gates:** `dispatch-gates --commands` (no paths) derived 50 commands
from the 2-file change set. `--ran` reports `50 derived, 50 run, 0
NOT-MEASURED, 0 UNRUN`, a derived zero: every command recorded `exit 0`.
`check:type-check-debt` and `check:dual-build-cjs-loads` ran last, after
every locked suite.
- `dispatch-gates` noted that the tree is behind `origin/main`: 6
derived-from files changed there (`ci.yml`,
`check-shard-attestation.mjs` and the fleet-write relay scripts).
- No conflict exists, so per the round's direction there was no merge.
CI judges the merge ref.
- **Lint, proven narrowing:**
1. Population is read from ESLint's own config: both changed files
return results with no "File ignored".
2. File count is from `--format json`: 2 results, 0 errors, 0 warnings.
3. Invariance: `eslint.config.mjs` enables no type-aware linting, and
this diff touches neither the config nor a baseline it reads.

## Changeset

Nothing publishes: `@objectstack/cli` ships `dist`, `README.md` and
`CHANGELOG.md`, and this diff is two `test/` files. By the repo's rule
this PR takes the `skip-changeset` label. An empty-frontmatter changeset
would be the risky path that `check:empty-changeset` refuses. The seat
applies the label.

## Acceptance notes

- `objectstack migrate meta --write` on a project whose path has a
`node_modules` segment says `the literal is in …/objectstack.config.ts,
outside the project at …`, although the file is inside the project. The
real cause is the `node_modules` segment rule. Only a test layout
reaches it, so this is a wording polish, not a finding. No carrier.
- Version Packages PR #21988 was last refreshed on 2026-10-08T06:19Z,
before pre mode was entered (`a87d8be29`). This is release-lane state,
recorded as a reading, ⛔ not acted on.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

C7a (ADR-0131 D10, split from #15211): the migration inventory file, the read-only os migrate --plan, and the design of the ceremony's completion marker

2 participants