Skip to content

feat(cli,plugin-sharing)!: an unattributable organization-less row blocks the ownership ceremony's certification (ADR-0131 C8 stage 8c-0) - #24403

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-15212-8c0-unattributable-blocks
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-15212-8c0-unattributable-blocks

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #15212
Clause-②: yes (narrowing)

C8 stage 8c-0 (plan 6251466182 on #15212 and its amendments): it executes ruling C on decision #24352 (record 6251599171, verbatim choice: 「C 升级时拦住 (Recommended)」). #15212 stays open for 8b to 8d.

What changes

The ownership ceremony's post-check holds every unattributable row against certification (packages/cli/src/utils/organization-ownership-apply.ts).

  • Both populations the ruling names live in one field of the plan, TablePlan.unattributable (organization-ownership-plan.ts, walkNullRows): fate 4's reported rows (reason fate 4: reported, never guessed), and a fate-3 row no anchor names under a wall (reason no anchor names the owner, sys_file row X not found, and so on). The plan already classified them the same way. Only the apply's verdict counted them as advisory, so the fix is in the verdict, not in the plan or the inventory.
  • remainingFor now adds them to the table's remaining, so the run's blocking counts them and the sys_migration marker stays unverified. A v18 server does not start on such a database: since fix(cli,runtime): the 17 → 18 ownership ceremony leaves no row without an organization on a single-posture database, its prescribed boot writes nothing before the guard refuses, and an unverified ceremony is not served #24325 an unverified ceremony is not served.
  • Each table that holds such a row carries held: { rows, remedy } in the report file. The terminal output names the table, its count and the remedy (UNATTRIBUTABLE_REMEDY): give the row its organization, or delete it by hand, then re-run --apply.
  • ⛔ The ceremony still never guesses an owner and never deletes such a row (ADR-0131 D10 fate 4). The pins assert that the row is still present and still NULL after the blocking run.
  • advisory now counts only two things. The first is an advisory departure's rows. The second is the reported rows of a fate-4 table whose column is not the tenancy anchor (InventoryEntry.notNullExempt: sys_sso_provider, the retired sys_scim_provider). See the open question below.
  • The plan's summary line says that such rows will hold --apply's certification.

An advisory DEPARTURE stays advisory (A1). It is a different population: a release whose own boot re-creates the rows, so no step of that release can remove them. The only member is the declared sharing-rule mirrors while DECLARED_SHARING_RULE_SEEDER_RUNS is true, and that has been false since stage 9. Blocking on it would block a database for rows its own release keeps writing, which no remedy can clear. The code path is unchanged.

os dev's in-place upgrade reads the same verdict (A3) (dev-organization-ownership-upgrade.ts). Before this change it booted again on report.complete alone. A completed run whose post-check held rows then hit the boot guard's refusal a second time and ended os dev. Now it boots only on complete && blocking === 0, the marker the guard reads. Otherwise it prints each held table with its count and remedy, and the resume command.

The declared-sharing-rule port answers SERVED names (A5, route (a)).

  • @objectstack/plugin-sharing exports translateDeclaredSharingRule (with DeclaredSharingRule and DeclaredSharingRuleSkip). It is the existing, pure, unchanged translation enforcement serves a declared rule through.
  • The ceremony judges each registry item through it (judgeDeclaredSharingRules), so DeclaredSharingRulesPort.rules() answers each declared name with refused: null or the reason.
  • runDeclaredRuleMirrorDeletion renames a mirror's grants only onto a served name. While grants still name a mirror of a refused declaration, the step blocks, naming the rule, its rows and the translation's reason, and writes nothing. A refused mirror that no grant names is still deleted.
  • This is the (b) behaviour ("refuse before renaming"), carried by (a)'s measured read. (b) alone would have needed a second copy of the translator in the CLI to name "the compiler's reason".

Operator text (A4). content/docs/deployment/cli.mdx gains the verdict: what blocks, the remedy, that the marker means no organization-less row, and the not-the-tenancy-anchor exception. The os dev paragraph now says the upgrade also stops on post-check holds. Measured: install-modes.mdx holds no sentence about the ceremony's verdict (its only ADR-0131 mention is D6 install modes), so it is unchanged. Nothing under content/docs/releases/ changed.

The A5 route on the four axes

  • 实际业务需求: reachable only from an older release's seeded copies whose seeder accepted what v18 refuses (amendment 6254164222). Measured: v18's refusal set is a subset of the retired seeder's, so a database last booted on stage 9's predecessor cannot reach it. The harm is real when it happens: a grant silently revoked at the next boot.
  • 项目长远合理性: (a) keeps ONE definition of "served", plugin-sharing's own translator. (b) without (a) would put a second copy of that translator in the CLI, or a warning that cannot name the reason.
  • 防 AI 犯错: the step blocks loudly, naming the rule and the reason, instead of renaming onto a name the plugin will refuse. That follows ruling C's spirit: block, never guess.
  • 创业阶段不扩散: one re-export of an existing function. No new gate, no new tool, no new port beyond the renamed method.

Clause-② (measured)

yes (narrowing), where the claim said no (narrowing). Route (a) widens plugin-sharing's public surface by one export, at minor.

  • Narrowings: --apply exits 1 and leaves the marker unverified where it used to certify. os dev no longer boots on a complete-but-holding run. The mirror step blocks where it used to rename onto a refused rule.
  • Widening: translateDeclaredSharingRule is exported.
  • The changeset .changeset/15212-8c0-unattributable-rows-block.md carries each narrowing as FROM / TO / Fix, and the ADR-0087 disposition not-required (no-migration-prescription): no spec key, export, option or stored shape is renamed or removed, and the remedy is an operator data act the ledger has no reader for. check:adr-0087-registration is green on it.

Landing points outside the claim's file surface

  • packages/cli/vitest.config.ts: one anchored alias, @objectstack/plugin-sharing to its source. check:test-source-alias reds without it, because the ceremony's tests now import plugin-sharing, and its registry is shrink-only.
  • organization-ownership-inventory.ts and organization-ownership-plan.ts are in the claim's surface and are unchanged. Measured (A2): both populations were already classified as one field. The verdict lived only in the apply.
  • packages/plugins/plugin-sharing/src/declared-sharing-rules.ts is unchanged. The export is in index.ts alone.

Tests

On head 319935d6e (after merging origin/main db4618b5c, which carries stage 8a):

  • Every organization-ownership* test under packages/cli/src/utils and packages/cli/src/commands/migrate, plus dev-organization-ownership-upgrade.test.ts: 22 files passed and 1 skipped by its own guard. 260 tests passed.
  • @objectstack/cli typecheck (it compiles the tests) passed. @objectstack/plugin-sharing typecheck and test passed (48 files, 1,139 tests).
  • @objectstack/cli vitest run --project unit passed (311 files, 4,610 tests). The CLI's integration tier is declared to CI; the ceremony's own integration-tier files are in the first line above.
  • Gates: dispatch-gates --commands derived 101 families. All 101 ran, plus the 51 artifact-roster families, on this head. --ran reads 101 accounted for, 0 NOT-MEASURED. Four roster families need a PR context, so they refused locally (check-closing-target-claim, check-single-claim-paths, check-governed-queue-guard, and check-partof-closing-keyword without a body); CI runs them on this PR. check-partof-closing-keyword passed locally on this body.
  • New pins:
    • The acceptance fixture's fate-4 orphan now holds: blocking 1, the ledger verdict { complete: true, blocking: 1, advisory: 0 }, held on sys_saved_report with the remedy, and the row still present and still NULL.
    • The remedy, both ways: delete the row by hand, or give it its organization. The re-run certifies [true, 0, 0].
    • Walled fate 3: an upload session whose file is gone, under isolated. It blocks, names sys_file row file_gone not found, and the row is untouched. Assigning its organization by hand certifies.
    • Control: the same walled database with the anchor intact certifies [true, 0, 0].
    • Control: a sys_sso_provider row with no organization is reported, advisory 1, and certifies.
    • Mirror step: a declared-but-refused rule that a grant names blocks with the reason, and nothing is renamed or deleted. One that no grant names goes.
    • judgeDeclaredSharingRules over the real translator: served, untranslatable condition, dotted field recipient, match-all, and nameless.
    • os dev: a complete run that holds rows does not boot, and names the table, count and remedy.
  • Ablation (scripts/ablation-replace.mjs, anchor hit 1 → 0, blob dcb8dd2b → 074e0a99, then restored to dcb8dd2b): with heldUnattributable forced to 0, five pins in organization-ownership-apply.test.ts go red, the walled fate-3 pin and the acceptance fixture among them. Restored, all green.

Acceptance notes

  • Open question for the seat: the notNullExempt exception. Ruling C says "an unattributable organization-less row". On sys_sso_provider (tenancy disabled; better-auth's provider relation) and the retired sys_scim_provider (left in place untouched, by ruling), an organization-less row is legitimate. Neither table is tenant-scoped, so 8c's NULL-reader retirement does not read it. Blocking on these rows would force an operator to guess an owner or delete a legitimate SSO provider, which D10 fate 4 forbids. So they stay reported and advisory. If the seat reads the ruling as "every table", it is a one-line change in heldUnattributable, and its control pin flips.
  • The D13 wording in 18.sharing-rule-declared-mirror-retired.ts moved to stage 8e, per the claim. packages/spec/src is untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BfSp42RND2f8bWLn2EC4w9


Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/plugin-sharing, touching 16 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/cli/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))
  • content/docs/deployment/cli.mdx (via os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))
  • content/docs/permissions/capabilities.mdx (via os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))
  • content/docs/permissions/permission-sets.mdx (via os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/cli/vitest.config.ts) — pages documenting those are invisible to this run
  • 2 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 — 36 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 d79dc4883c5698cf05680a545758bdbba0d8a7a2 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json d79dc4883c5698cf05680a545758bdbba0d8a7a2

⚠️ 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 d79dc4883c5698cf05680a545758bdbba0d8a7a2 → 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: 319935d6ece1da93e202f8bc5d3184c927bfa757
Local-runs: none
Scope: packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts, packages/cli/src/commands/migrate/organization-ownership.ts, packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts, packages/cli/src/utils/dev-organization-ownership-upgrade.ts, packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts, packages/cli/src/utils/organization-ownership-apply.test.ts, packages/cli/src/utils/organization-ownership-apply.ts, packages/cli/vitest.config.ts, packages/plugins/plugin-sharing/src/index.ts + packages/cli/src/utils/organization-ownership-plan.ts (walkNullRows, TablePlan.unattributable, summary.unattributableRows), packages/cli/src/utils/organization-ownership-inventory.ts (notNullExempt rows sys_sso_provider and sys_scim_provider, DECLARED_SHARING_RULE_SEEDER_RUNS, advisoryDepartureReason), packages/plugins/plugin-sharing/src/declared-sharing-rules.ts (translateDeclaredSharingRule, DeclaredSharingRuleSkip; byte-identical to origin/main), packages/runtime/src/organization-ownership-boot-guard.ts (readMarker, assertOrganizationOwnershipMigrated), packages/spec/src/system/migration.zod.ts (isDataMigrationFlagVerified), packages/platform-objects/src/system/migration-flag.ts (recordDataMigrationRun), packages/platform-objects/src/identity/sys-sso-provider.object.ts, packages/core/src/security/tenant-layer0-verdict.ts, packages/objectql/src/engine.ts (buildDriverOptions's tenantObject), packages/cli/package.json with packages/cli/src/index.ts, console.ts and hook-body.ts (the exports map), packages/plugins/plugin-sharing/package.json, scripts/pm/clause2-line.mjs, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, scripts/check-test-source-alias.mjs (the @objectstack/cli registry entry), packages/spec/src/migrations/entries/semantic/18.sharing-rule-declared-mirror-retired.ts (unchanged; the out-of-scope note). The two prose faces, .changeset/15212-8c0-unattributable-rows-block.md and content/docs/deployment/cli.mdx, are judged in ①(f) and ②.

Rendered by an isolated at-tier reviewer subagent of the adopting seat (session_01Y5583bs2RDd53MH6kM1ca3), read-only: the net diff origin/main...refs/review/pr-24403 (11 files, +474/−62, merge base db4618b5), the PR body and its one bot comment, card #15212's body and all 20 comments (plan 6251466182, amendments 6251607592 / 6252974640 / 6253205661 / 6254164222 / 6254733398, claim 6254514683, report 6255756719), decision #24352 and ruling 6251599171, ADR-0131 D10 / D13 / D14 on origin/main, PR #24376, and the head's check-runs. Head re-confirmed 319935d6e at 2026-10-18T22:36Z.

Check-runs on the head (the gate verdicts): 41 runs, 36 success, 5 skipped (Console Pin Gate, the dogfood matrix and verify, the packed-tarball smoke, Temporal Conformance — each skipped by its own path filter), 0 failures. Read three times: at 22:25Z Test Core 2/6, 3/6 and 4/6 were in_progress; at 22:33Z only 3/6 was; at 22:36Z all six are success. Check Changeset, Governed Surface Queue Guard, every Lint · and Type Check · leg, Spec property liveness and the three claim gates are success.

① Derived judgments

(a) Ruling C, executed exactly — right.

  • Both populations the ruling names already live in one field. organization-ownership-plan.ts walkNullRows (:1145–:1230) pushes every NULL row left after the departures and the anchors into TablePlan.unattributable, reason fate 4: reported, never guessed on a fate-4 table and no anchor names the owner / PARENT row X not found / PARENT row X has no organization / the holder reasons on a fate-3 table; under single the Default Organization takes every fate-3 leftover, so a fate-3 row survives only under a wall. The plan and the inventory needed no change (A2 holds; neither is in the diff).
  • The verdict moved where the defect was. organization-ownership-apply.ts:2898 heldUnattributable = table.unattributable.length unless the inventory row is notNullExempt; remainingFor (:2906, :2908) adds it for the attribution and report fates; the post-check sums remaining into discrepancies when every step is complete (:3159), blocking = discrepancies (:3203), and the ledger write (organization-ownership.ts:784–793) passes passed: complete && blocking === 0 to recordDataMigrationRun, which stamps verified_at: null and blocking: N when not passed (migration-flag.ts:198–200). isDataMigrationFlagVerified (migration.zod.ts:278–281) requires verified_at set AND blocking === 0; the boot guard reads it (organization-ownership-boot-guard.ts:313), reads unverified, and throws (:357). So the marker stays unverified and the v18 boot is refused, exactly as fix(cli,runtime): the 17 → 18 ownership ceremony leaves no row without an organization on a single-posture database, its prescribed boot writes nothing before the guard refuses, and an unverified ceremony is not served #24325 serves it. walkNullRows runs only for the attribution and report fates (plan.ts:1056), so the column-drop and mirror-deletion arms of remainingFor are rightly untouched.
  • Each table, its count and the remedy: TableOutcome.held = { rows, remedy } (:521, :3193) with UNATTRIBUTABLE_REMEDY (:2887–2890); the command prints a red per-table line (organization-ownership.ts:1031–1036) and a summary printError naming every holding table with its count and the remedy (:1060–1064); verdictCode is 1 when blocking is non-zero (:1007, :1088).
  • Nothing guessed, nothing deleted: no write path is touched; the pins assert the row is still present and still NULL after the blocking run (acceptance fixture report_orphan; walled us_orphan with no DELETE on sys_upload_session). ADR-0131 D10 fate 4 stands.
  • A clean database still certifies, with controls: the walled control (file_id = 'file_a') and both remedy re-runs read [true, 0, 0]; the stage-9 integration test on a booted stack keeps [0, true, 0, unattributable] and now also pins held === []. The pre-existing acceptance fixture rightly flips to blocking: 1, and the ablation (heldUnattributable forced to 0) reddens five pins including the walled one.

(b) The notNullExempt exception stays advisory — A is right on the measurement; it is a reading of the ruling the seat records, not the dev's to decide.

  • Measured: sys-sso-provider.object.ts:59 declares tenancy: { enabled: false }; its organization_id (:331–337) is "Organization scope (when org-scoped SSO is used)", better-auth's provider relation, written through the adapter with no tenantId; the object's own comment (:36–48) says a wall that AND-composed organization_id = caller org would deny every row, which is why it opts out. engine.ts:6887 makes tenantObject = !isTenancyDisabled(schema) and withholds the driver scope for such an object (the :9476 docblock says the same), so the orWhereNull arms 8c-1 retires never run on it; computeTenantLayer0Verdict returns LAYER0_NONE on tenancyDisabled (tenant-layer0-verdict.ts:137). git grep on the head finds no reader among the 8c-2/8c-3 retirements (the authz resolver, sharing-rule-service.ts, settings, meta-write scope, bootstrap capabilities) that names sys_sso_provider or sys_scim_provider; the only non-test hits are plugin-security's private-object list and a comment. sys_scim_provider is retired with its table left in place untouched by the 2026-08-24 ruling, and no declaration anchors tenancy on it.
  • So an organization-less row there is not "a row whose owner cannot be derived" (「找不到主人的行」, the class the maintainer chose C for): it has no owner by design, nothing 8c-1 to 8c-3 retires can darken it, and the NOT NULL exemption for both tables pre-dates the ruling (the inventory on main; the plan's notNull.willReceive reading at plan.ts:1117). B would make an operator bind a deployment-wide SSO provider to one organization (a guess that changes sign-in scope) or delete it — the harm D10 fate 4 forbids, moved onto the operator. Answer: A, pinned by the control (sso_global reported, advisory 1, [true, 0, 1]).
  • The boundary: ruling 6251599171's "What it binds" reads "The verified marker then means zero NULL rows on every table (ruling β item 1)", and [Decision] C8: a certified database's leftover organization-less rows — block certification, or go dark when the NULL arms retire #24352's body quotes β item 2, "Nothing else is exempted". A reads "every table" as "every table the tenancy anchor governs" — a narrowing of the recorded letter that neither the claim nor the ruling granted. It is not blocking for this head (it changes no behaviour a tenant-scoped read sees), but it is the seat's to record, not to carry silently: the adopting seat posts reading A on [Decision] C8: a certified database's leftover organization-less rows — block certification, or go dark when the NULL arms retire #24352 inside the maintainer's veto window, as the plan does for D3, naming B as the one-line fallback (heldUnattributable's notNullExempt ? 0 arm, the control pin flips). See ③ item 8.

(c) The advisory DEPARTURE stays advisory — right. advisoryDepartureReason (organization-ownership-inventory.ts:693–703) answers null for every departure while DECLARED_SHARING_RULE_SEEDER_RUNS is false (:676, since stage 9), so on this release no departure is advisory: the declared-rule mirrors count as remaining and the mirror-deletion step performs the departure. The advisory arm is live only on a release whose boot re-seeds the rows; holding certification there would block a database on rows its own release keeps writing, with no remedy that clears. That population is a departure, not an unattributable row, so it is outside ruling C. Code path unchanged; its pins stand.

(d) os dev boots only on complete && blocking === 0 — consistent with the serving guard, and no stuck state. The test (dev-organization-ownership-upgrade.ts:173) is exactly the ledger's passed and the blocking === 0 leg of isDataMigrationFlagVerified; before it the loop in the docblock ("A boot refused again after a finished upgrade ends os dev") was reachable from a completed run with holds. The non-boot path prints each table with remaining above zero — held tables with held.remedy, any other with "the report names each" — then where the copy is and the resume command (:176–188); pinned (sys_saved_report: 2 row(s) …, two lines, the --apply --plan-file … --backup-taken command). report.blocking !== 0 treats an absent blocking as holding, a safe default since the same CLI writes the report it reads.

(e) The A5 route (a) — right, minimal, graded, and the block is clean.

  • Export: plugin-sharing/src/index.ts:103–110 re-exports translateDeclaredSharingRule with DeclaredSharingRule and DeclaredSharingRuleSkip from declared-sharing-rules.js; the module is byte-identical to origin/main (the function is pure, :168–221). The three names are the function and the two components of its return type — the minimum that makes the export typeable. Graded minor on @objectstack/plugin-sharing. One definition of "served" (enforcement's own translator); the CLI's describeDeclaredRuleSkip (apply.ts:289–300) is a switch exhaustive over the kind union, so a new skip kind fails the CLI's typecheck rather than falling through — a second spelling of the reasons, not a second translator.
  • Port: judgeDeclaredSharingRules (apply.ts:276–286) drops nameless items and judges the rest through the translator; the command's rules() (organization-ownership.ts:813–825) feeds the registry's sharing_rule items, else the metadata service's — the two readers enforcement uses since stage 9. @objectstack/plugin-sharing is already a workspace:* dependency of the CLI (packages/cli/package.json:84, pre-existing), so the dynamic import resolves in the published CLI.
  • Block: runDeclaredRuleMirrorDeletion reads mirrors and counts grants (reads only), computes unnameable as mirrors with grants whose name is not served (:2653), splits undeclared from refused, and returns blocked(blockers) (:2680) before the first UPDATE (:2683 onward). Nothing is written before the block; the step is blocked, the ledger reads complete: false, and a re-run re-reads — idempotent. Each refused blocker names the rule, its rows, the translation's reason and the remedy (:2669–2679). A refused mirror no grant names is deleted like an undeclared one — stage 9's rule, unchanged. On a duplicate name served wins over refused (:2640–2644), the right precedence. Pinned both ways, and judgeDeclaredSharingRules is pinned over the real translator (served, untranslatable, dotted field recipient, match-all, nameless).
  • DeclaredSharingRulesPort.names() → rules(): CLI-internal. packages/cli exports are ., ./console, ./hook-body and ./package.json; src/index.ts, src/console.ts and src/hook-body.ts export nothing from utils/organization-ownership-*; the only implementers on the head are the command and the two test files. No FROM → TO pair is owed for it.

(f) The operator text (content/docs/deployment/cli.mdx), sentence by sentence — true against the code.

  • "A row whose owner cannot be derived blocks certification." — heldUnattributable → remaining → discrepancies → blocking. ✓
  • "That covers a reported row, and a row whose anchor is missing under a wall (for example an upload session whose file is gone)." — fate 4 and the walled fate-3 leftover; inventory :475 anchors sys_upload_session.file_id on sys_file; pinned as sys_file row file_gone not found. ✓
  • "The ceremony never guesses an owner for such a row and never deletes it." — no write path; pinned. ✓
  • "The post-check names each table, its row count and the remedy: give each row its organization (write its organization_id, or repair the anchor its reason names), or delete it by hand, then re-run --apply." — held, UNATTRIBUTABLE_REMEDY, the per-table and summary lines. ✓
  • "Until then the run exits 1, the marker stays unverified, and a v18 server does not start on the database." — verdictCode, recordDataMigrationRun (verified_at: null, blocking: N), the guard's unverified refusal. ✓
  • "A verified marker therefore means no organization-less row on any table. The one exception is a table whose organization_id is not the tenancy anchor (sys_sso_provider, and the retired sys_scim_provider): a row there bound to no organization is legitimate, is reported, and does not block." — matches heldUnattributable and the control pin. ✓ (Wording: the first sentence says "any table" and the next takes one back; see ③ N1.)
  • The os dev paragraph (:213–215): "If a step blocks, or the post-check still holds rows (a row whose owner cannot be derived, named per table with its remedy), os dev stops and prints what holds it, where the copy is, and the command that resumes the run." — holds, kept, again in upgradeDevelopmentDatabase. ✓
  • install-modes.mdx is unchanged, and its only ADR-0131 sentence is about D6 install modes (measured by the dev; not in the diff). Nothing under content/docs/releases/ changed. ✓

② Semver level

  • Clause-②: yes (narrowing) — right. scripts/pm/clause2-line.mjs names yes (narrowing) as the legal combination for "a diff that widens one surface and narrows another"; the widening is plugin-sharing's new export, the narrowings are the four below. The PR body (line 2) and the changeset (line 8 of the body) carry the same line. The claim's no (narrowing) was written before the A5 route was chosen; amendment 6254164222 priced route (a) as "a Clause-②: yes widening at minor", so the measured arm is the planned one.
  • FROM → TO pairs: four, complete and true, each with a one-line fix. (1) --apply on a database holding an unattributable row: exit 0 / advisory / verified → exit 1 / blocking / unverified (verdictCode; the ledger's passed; isDataMigrationFlagVerified). (2) A database an earlier --apply certified while it held such a row: re-certified → unverified on the next run (the resume runs the post-check; recordDataMigrationRun writes verified_at: null and blocking: N). (3) os dev boots on complete → boots on complete and blocking 0. (4) The mirror-deletion step renamed onto any declared name → blocks while grants name a mirror of a refused rule. Nothing is missing: the port rename is internal (①(e)), TableOutcome.held is additive, and the --plan warning text is prose. The Fix lines are operator data acts plus the one code act (correct the declaration), one line each.
  • ADR-0087 marker — right disposition, right spelling. Exactly one adr-0087 HTML-comment marker, not-required (no-migration-prescription) …why…, the grammar check-adr-0087-registration.mjs reads (header :63; CATEGORIES :536–543 holds the category). The category's meaning holds on the facts: no spec key, option, export or stored shape is added, removed or renamed; nothing retires, so registered is not owed and runtime-export-retired, runtime-interface-only and type-surface-only do not apply; the remedy is an operator data act objectstack migrate meta has nothing to rewrite for. The bold FROM / TO: / Fix: lines are the house label convention the gate's branch 1 reads as a label, not a code-rewrite prescription (:718–723); the gate's legs (Lint · CI, release & engine gates, Lint · repo & tooling gates) are success on the head, and the gate reads the (narrowing) arm through readClause2Line (:683–684) so the marker was demanded and found.
  • Bumps in the launch window — right. @objectstack/cli: minor, @objectstack/plugin-sharing: minor; no major (check-changeset-no-major.mjs's launch-window guard); the LEVEL axis (a yes declaration grades at least one moved package minor) is met twice; Check Changeset is success. The **BREAKING** (narrowings), shipped as minor under the launch-window convention banner is the convention the gate states.
  • Nit, not wrong: the changeset tells CHANGELOG readers about DeclaredSharingRulesPort.rules(), "formerly names()", a surface no consumer can reach.

③ Boundary flags

  1. packages/cli/vitest.config.ts alias outside the claimed surface — accepted. scripts/check-test-source-alias.mjs:380–388 lists the CLI's known unaliased test imports without @objectstack/plugin-sharing; the registry is shrink-only (:316); the new value imports (organization-ownership-apply.declared-rule-mirrors.test.ts:58, the dynamic import in apply.ts:277) therefore red the gate without an anchored alias. One anchored entry (^@objectstack\/plugin-sharing$ → ../plugins/plugin-sharing/src/index.ts), spelled as its neighbours; declared in the body and the report.
  2. yes (narrowing) against the claim's no (narrowing) — answered in ②: the measured arm, planned by amendment 6254164222 for route (a), declared identically on the body and the changeset.
  3. The changeset FROM/TO form — answered in ②: the bold label form is the gate-readable house convention; the gate is green and every pair is present.
  4. The port rename — answered in ①(e): CLI-internal by the exports map; nothing owed.
  5. The merge of origin/main (db4618b5, stage 8a) — accepted. AGENTS.md's multi-agent discipline (§7, §9) names git merge origin/main as the way a branch picks up main; the merge was clean, the dev re-ran the suites on the merged head, and the squash landing drops the merge commit.
  6. The wip commit subjects — accepted. The landing squashes (AGENTS.md :486), so the PR title is the subject that lands; each of the five commits carries the model-free trailer pair (Claude-Session, Co-authored-by: Claude), the one commit-message rule AGENTS.md states (:460); the merge commit is git-generated and is discarded by the squash.
  7. The out-of-scope note — confirmed. 18.sharing-rule-declared-mirror-retired.ts:32–33 still carries the D13 paraphrase on this head; packages/spec/src is untouched; row 8e carries it per claim 6254514683 and amendment 6254164222.
  8. Escalation (①(b)) — the seat's act before the queue, non-blocking for this head. Record reading A of ruling C on [Decision] C8: a certified database's leftover organization-less rows — block certification, or go dark when the NULL arms retire #24352: the marker means zero NULL rows on every table the tenancy anchor governs; sys_sso_provider and the retired sys_scim_provider (notNullExempt) are reported and advisory because their organization_id is not the tenancy anchor and no retiring NULL reader reads them. Name B as the one-line fallback. The maintainer may veto; a veto is a one-line follow-up on heldUnattributable and its control pin, not a reopening of this stage.

Non-blocking wording items (prose only; a wip or a follow-up may carry them):

  • N1 content/docs/deployment/cli.mdx:1498–1500 — fold the qualifier into the sentence: "A verified marker therefore means no organization-less row on any table the tenancy anchor governs; the one exception is …", so the paragraph does not state "any table" and then take one back.
  • N2 packages/cli/src/commands/migrate/organization-ownership.ts:628 — "(but on a table whose column is not the tenancy anchor)" reads as a contradiction to an operator; write "except on a table whose column is not the tenancy anchor". The same "but on" idiom is in the heldUnattributable docblock (apply.ts:2893–2897) and the command's comment (:1054).
  • N3 content/docs/deployment/cli.mdx:215 — one 120-character line in a wrapped paragraph; reflow.

Implemented-by: claude/issue-15212-8c0-unattributable-blocks
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 18, 2026 22:43
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 18, 2026 22:43
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit 78652c0 Oct 18, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15212-8c0-unattributable-blocks branch October 18, 2026 23:06
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants