Skip to content

feat(service-storage)!: retire the sys_file scope option public and rewrite stored public rows to user (#22443) - #22552

Merged
objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-22443-sys-file-public-scope-backfill
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-22443-sys-file-public-scope-backfill

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22443
Clause-②: yes (narrowing)

The second half of #22443, as ruled (ruling 6081131776, letter B): the sys_file.scope option public retires, and rows already stored with it are rewritten to user by a one-time operator sweep in the shape of the sys_file organization backfill. The first half (PR #22469, merged as ee8751d41e) retired public from StorageScopeSchema and refused it at both upload doors; it is not redone here.

The ruling, verbatim:

B — the option retires, and the stored rows are rewritten to user. After PR #22469 lands, a follow-up PR removes the public option from sys_file.scope and adds a one-time backfill in the shape of the organization backfill (backfill-sys-file-organizations.ts): every stored public row becomes user, the scope value the copy path itself defaults to and the one no reader distinguishes from public today, so no access behaviour changes; the storage key and the file bytes are untouched; the backfill counts before it writes and is a no-op where the count is zero; its inverse (user → public on the touched ids) is the rollback. Pins: a copy of a rewritten row succeeds; the no-row deployment runs clean. The changeset states the data rewrite; Clause-②: no (narrowing); contract review before the queue.

What lands

  • sys_file.scope (packages/services/service-storage/src/objects/system-file.object.ts): the public option is gone. The options are user, tenant, private, temp, attachments. The engine now refuses a write of public to the column: VALIDATION_FAILED, invalid_option on scope.
  • The sweep (packages/services/service-storage/src/backfill-sys-file-public-scope.ts, new): planSysFilePublicScopeBackfill / applySysFilePublicScopeBackfill / runSysFilePublicScopeBackfill / formatSysFilePublicScopeBackfillReport. It scans scope = 'public' in id order, reads the whole population before writing, writes ONE column (scope: 'user') per row, and records a failed row without retrying it. It is a dry run by default, and it reports a scan it could not make, or a scan that stopped at its row ceiling, instead of reading as clean. Its four functions and their report types are exported from the package entry, beside backfillFileReferences (patch round 1, seat answer Q2): a deployment runs the published package, not a source checkout, and until the sweep runs a copy of a stored public row is refused.
  • Translations: the four objects.generated.ts bundles lose their public option label (regenerated with check-i18n-bundles.mjs --write; no other key moves).
  • The step-18 D3 entry storage-scope-public-retired (packages/spec/src/migrations/entries/semantic/18.storage-scope-public-retired.ts, plus registry.ts regenerated with gen:migration-registry; seat answer Q3): its reason no longer says files already stored with scope public are not touched. It names the sys_file option's retirement and the operator sweep the package exports, which rewrites those records to user with no access change, and its acceptance criteria add the sweep's end state (a dry run scans zero such records). No spec-changes.json / upgrade-guide regeneration is owed: since spec(changes): generate the per-major spec-changes section and the upgrade guide at publish; the pull request generates both in memory and renders the diff (#22449 B′, condition 1) #22533 both are generated in memory at check time and at publish.
  • The tenant-audit census (seat answer Q1): node scripts/tenant-audit-census.mjs --write places the sweep's one write as it places the organization backfill's (update · sys_file · tenancy enabled · context of undecidable elevation), and the hand-written page counts the gate holds move with it: 241 write call sites (from 240), 160 decidable, 105 of undecidable elevation, 53 named through a const.
  • Changeset .changeset/22443-sys-file-public-scope-rewrite.md: minor on @objectstack/service-storage and patch on @objectstack/spec (the entry text), declared breaking (Clause-②: yes (narrowing): the entry export widens the package surface while the option retirement narrows the stored vocabulary), disposition not-required (already-registered storage-scope-public-retired). It states the data rewrite, the operator step (the package import) with the exact failure it prevents, the rollback, and the entry's correction.

No governed surface is touched. 13 files, +757 / −40 (797 changed lines; numstat against merge base 4638625e07).

Measurements: the four hypotheses

All on a real ObjectQL over a real SqlDriver (sqlite :memory:), with the real SystemFile (its public option removed) and the real field-reference hooks. A stored public row is written through the DRIVER, because the engine on this branch refuses to write one, which is the state of a deployment upgrading from the previous release.

H1, nothing reads public. Holds. Re-measured at faf6348508, across packages/** and at the objectui pin f0268ad784. The only scope value any code compares against is attachments:

  1. storage-routes.ts:457, the download doors' gate (file.scope === 'attachments' || fieldOwned).
  2. attachment-lifecycle.ts:103, the tombstone hook (only attachments-scope committed files).
  3. attachment-lifecycle.ts:663, the reap guard (attachments-scope or not).
  4. stranded-orphan-inventory.ts:290 and :303, the orphan inventory (where: { scope: 'attachments' }).
  5. verify-file-references.ts:289, the reference verifier (skips attachments-scope rows).
  6. file-reference-lifecycle.ts:439, copyOwnedFile. It does not branch on the value: it copies the source's scope onto the copy and uses it as the copy's key prefix, defaulting a missing one to user.

Not readers: the local and S3 adapters read neither the scope nor the key prefix. sys_upload_session.scope is free text written at the chunked start and never read back. The upload gate at storage-routes.ts:388 reads the request, not a stored row. Outside service-storage, no package compares or filters on a sys_file scope. At the objectui pin, every .scope === comparison is about something else (settings, package manifests, CEL hints, SDUI props). So public → user changes no access behaviour.

H2, the window between upgrade and the operator's run. Measured. The failure is loud only in the server log. On an un-swept row:

  • reads (find, findOne, including where: { scope: 'public' }) pass the row through;
  • an update that does not write scope succeeds, with a system context or none;
  • a copy is refused: a record write naming a public file another field already owns reaches copyOwnedFile, whose insert carries scope: 'public' and is refused invalid_option. The hook throws ERR_FILE_REFERENCE_COPY (code, no status). Driven through @objectstack/rest's own classifiers (mapDataError and sendThrownError, a throwaway in-process probe), that is answered 500 { error: 'Internal server error', code: 'INTERNAL_ERROR' }. The sentence (… Scope must be one of: user, tenant, private, temp, attachments) reaches only the server log.

I judge the precedent's shape safe here and did not add a boot hook. The failure fails closed, nothing is lost or widened, and it is limited to deployments that stored public rows: no in-repo or objectui producer ever wrote one, and since PR #22469 no door can. The changeset names the operator step and this exact failure. The reachability gap, a sweep a published install could not import, is closed by seat answer Q2 below.

H3, the inverse. Measured. After the sweep, engine.update('sys_file', { id, scope: 'public' }) is refused VALIDATION_FAILED, invalid_option on scope (pinned). A raw driver write of public lands (measured in the probe, not pinned). So the rollback is the inverse on the ids the applied report lists (rows[], printed by the formatter), performed with a code rollback: on the previous release, through the engine there or as one raw driver statement against sys_file filtered to those ids. The module docblock and the changeset both say so.

H4, counts first and idempotent. Holds. scanned is the count taken before any write. A deployment with no public rows plans and writes nothing (zero sys_file updates reach the engine). A second run scans 0 and writes 0. Both are pinned.

Pins (backfill-sys-file-public-scope.test.ts, 10 cases)

  • a deployment with no public rows runs clean: 0 scanned, 0 written, zero engine updates, every row unchanged (ruling pin);
  • the default run is a dry run that names every row and writes none;
  • before the sweep, a copy of a stored public row is refused ERR_FILE_REFERENCE_COPY and leaves no new row; the row still reads, and a scope-free update lands; a direct insert of public is refused VALIDATION_FAILED / invalid_option on scope;
  • a copy of a rewritten row succeeds (copyOwnedFile, reached through the copy-on-claim hook). The copy is scope: 'user', keyed user/…, and owned by the new slot. The bytes are read from the source's untouched public/… key. The source row changed only its scope (ruling pin);
  • a second run writes zero;
  • paging by id covers the population without a skip;
  • a scan stopped at its ceiling says so, and the next run picks up the rest;
  • a scan that cannot be made (no sys_file registered) is reported, not read as clean;
  • the inverse write is refused on this release;
  • the operator step as the changeset writes it: the four functions and their report types imported from the package entry (./index.ts, from which the published dist is built) are the very functions the file drives, and plan, apply and a second run through the entry rewrite a stored public row and then write zero.

The copy pin sits in this file rather than beside file-reference-lifecycle.test.ts because it needs the rewritten row the sweep produces; it drives the real copyOwnedFile through installFileReferenceHooks.

Ablations (head dec868f9f8, both through scripts/ablation-replace.mjs in WRAP mode, restored with blob == HEAD, git diff HEAD empty and git status --porcelain empty, each checked on disk). The subject resolves through the test's relative src imports, not through a package exports / dist, so no rebuild sits between mutation and run.

  1. Skip the rewrite (the apply loop's engine.update never runs, but the row is still counted as written). Predicted and observed: 4 red, 5 green. The copy pin is red at the copy itself: Cannot copy file 'f_pub' for a second field reference … (Scope must be one of: user, tenant, private, temp, attachments). The second-run, ceiling and rollback pins are red too. The dry-run, no-row, pre-sweep, paging and unread-scan pins stay green.

  2. Restore the public option. Predicted and observed: 2 red (the pre-sweep refusal pin and the inverse-refused pin), 7 green, the copy pin included. A first attempt was void: its replacement text contained the anchor, so the tool refused before running anything. The reading above is the rerun with a non-overlapping replacement.

  3. Drop one export from the entry, before the entry pin existed (patch round 1, head 15e5891c77; runSysFilePublicScopeBackfill deleted from src/index.ts, rebuilt, and read from the built artifact: typeof is undefined in dist/index.js and dist/index.cjs, 0 hits in dist/index.d.ts). Nothing went red: the service-storage suite 49 / 830 passed, typecheck exit 0, and check:dts-closure, check:published-files, check:lean-entry-closure, check:dual-build-cjs-loads and check-undeclared-dep-imports all exit 0. So the entry pin above was added. Restored blob == HEAD, git diff HEAD empty; the restore leg rebuilt and read function back from both builds.

  4. The same deletion against the committed entry pin (head b107953555). Value leg: 1 red / 9 green, the entry pin (expected undefined to be [AsyncFunction runSysFilePublicScopeBackfill]), and typecheck red (TS2551, twice). Type leg (SysFilePublicScopeBackfillReport deleted from the type exports): the suite stays 10 / 10, as predicted (types erase), and typecheck is red (TS2724). Both restored blob == HEAD, git diff HEAD empty, git status --porcelain empty. The test reads ./index.js through a relative src import, so no rebuild sits between mutation and run.

Unmutated control: 10 / 10 passed.

Tests and gates

  • Patch round 1 merged origin/main twice through scripts/pm/os-regen-merge.sh: ebbb8f9c8d first (5551f8d8d5, which brings spec(changes): generate the per-major spec-changes section and the upgrade guide at publish; the pull request generates both in memory and renders the diff (#22449 B′, condition 1) #22533 into the branch), and 4638625e07 after PR fix(spec/automation): refuse a text-slot {{ $… }} hole whose root the flow engine does not bind #22499, which also writes registry.ts, landed (df6619318c). After the second merge, gen:migration-registry on the merged tree wrote registry.ts byte-identical to the text merge (409 semantic entries), so no regeneration commit was owed. No os-regen path owed one either.
  • service-storage, locked, at the final head df6619318c, after a full rebuild (72 tasks): vitest run, 49 files / 831 tests passed; typecheck (tsc, the scripts tsconfig, check:test-typecheck) exit 0.
  • @objectstack/spec, locked, at df6619318c: the four src/migrations tests plus build-migration-registry-entry, projection-cli and render-projection-diff, 7 files / 226 tests passed; typecheck exit 0.
  • Gates at df6619318c (and before at b107953555, the same 116, all exit 0): dispatch-gates --commands derives 116 commands (the spec families join because the entry and registry.ts moved). All 116 exit 0; --ran reads 116 run, 0 NOT-MEASURED, 0 UNRUN. check-tenant-audit-census and its --self-test are green (241 sites, 23 prose figures held). check-changeset-no-major's clause-② level axis, which needs a pull-request payload, was driven offline with --event for both body spellings: exit 0 for each.
  • Lint, narrowed and proven: eslint --no-inline-config --format json on the 6 touched .ts files gives 6 results, 0 errors and 0 warnings. The population is eslint.config.mjs's own TS glob. Invariance: that config enables no type-aware linting (parserOptions carries only ecmaVersion and sourceType), so an untouched file's verdict cannot move. The repo-wide pnpm lint is CI's.

Acceptance notes

  • The pre-sweep copy failure reads as a 500 to the client. That band is right for a condition only the operator can clear: the remedy is the sweep, not anything the caller can change. The server log carries the sentence. This is noted, not filed.
  • Until the sweep runs, the UI has no label for a stored public value, since the option is gone. Not measured in the console.
  • The first half's changeset still says the stored rows are not touched. .changeset/22443-storage-scope-public-retired.md (merged with PR feat(storage)!: retire the storage scope public from StorageScopeSchema and refuse it at the upload doors (#22443) #22469, unreleased) says files already stored with scope public "are not touched". After this PR, the sweep rewrites their scope column. That changeset is not edited here: check-empty-changeset refuses a diff that modifies a changeset from the merge base, by design. This PR's changeset states the correction instead ("The step-18 entry says so too"), in the same release's CHANGELOG for both packages. Seat's disposition: no further action.

Seat answers applied (6090887049)

  1. Q1 → A: the census moves in this PR (above).
  2. Q2 → B: the sweep is exported from the package entry, and the changeset's operator step names the package import.
  3. Q3 → A: the step-18 entry is corrected and registry.ts regenerated.

Generated by Claude Code

claude added 7 commits October 9, 2026 21:39
…d its one-time rewrite to user

The sys_file scope select no longer lists public: the value promised a public
file and stored a private one, and the upload doors already refuse it. Rows a
deployment already stored with it are rewritten to user by an operator-run
sweep in the shape of the organization backfill (plan / apply / run, dry run
first and by default, counts before it writes, a no-op at zero, idempotent).
One column moves; the storage key and the bytes do not.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…or step and its rollback

The test's engine reads now pass typed query options instead of erasing them.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…torage bundles

Regenerated with check-i18n-bundles --write; no other key moves.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…eady-registered storage-scope-public-retired)

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…s-file-public-scope-backfill

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…s-file-public-scope-backfill

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

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-storage, @objectstack/spec, touching 29 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-storage/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

24 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 6a3f82efa7d588267eb2e52c06a73c56315c636a.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-storage/src/index.ts) — pages documenting those are invisible to this run
  • 11 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 — 141 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 6a3f82efa7d588267eb2e52c06a73c56315c636a → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6a3f82efa7d588267eb2e52c06a73c56315c636a

⚠️ 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 6a3f82efa7d588267eb2e52c06a73c56315c636a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 6 commits October 9, 2026 23:20
…e package entry

plan / apply / run / format and their report types are exported beside
backfillFileReferences. Until the sweep runs, a copy of a stored public row
is refused, and a deployment consumes the published package, not a source
checkout, so the operator step has to ship in the release that asks for it.
The module docblock's usage section now imports from the package.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…ys_file option's retirement and its operator sweep

Its reason said files already stored with scope public are not touched. The
sys_file scope select now retires the option too, and the operator sweep
@objectstack/service-storage exports rewrites those rows to user with no
access change. The reason says so, and the acceptance criteria add the
sweep's end state. The changeset names the package import for the operator
step and gains a patch line for @objectstack/spec.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…edit

pnpm --filter @objectstack/spec gen:migration-registry; the only change is
the storage-scope-public-retired entry.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…enant-audit census

node scripts/tenant-audit-census.mjs --write over the generated region and
the counts ledger, plus the hand-written page counts the gate holds to it:
241 write call sites (from 240), 160 decidable, 105 of undecidable
elevation, 53 named through a const. The new site is placed as the
organization backfill's is: update, sys_file, tenancy enabled, context of
undecidable elevation.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…the operator step itself

Dropping one of the four exports from the entry left every test and every
derived export gate green (measured), so this case imports the sweep and
its report types from ./index.js, asserts the entry hands out the very
functions this file drives, and runs plan, apply and a second run through
the entry against a stored public row.

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

Copy link
Copy Markdown
Contributor Author

Contract review

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

PR #22552 (card #22443, ruling 6081131776 letter B, the second half), read against the net diff from its merge base 4638625e07 (13 files, +757 / −40), the card's body and all 13 comments, PR #22469 as merged (ee8751d41e, an ancestor of the merge base) for what is not redone, and the head's check-runs. Check-runs on the head: 42, all completed; 38 success, 4 skipped (Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke: path filters and an opt-in), 0 failure, 0 in progress; the combined commit status is success (1 context). Lint & Repo Gates, which runs check-tenant-audit-census, check-adr-0087-registration, check-empty-changeset and the migration-registry match, and Check Changeset (two runs), which runs check-changeset-no-major with the PR payload, are green.

① Derived judgments

  1. sys_file.scope accept set narrows: public leaves the select (system-file.object.ts); user, tenant, private, temp, attachments remain. Right, and the ruling's first clause. The engine's refusal (VALIDATION_FAILED, invalid_option on scope) is pinned on a real ObjectQL over SqlDriver for an insert and for the inverse update. The four generated translation bundles lose the option label and nothing else. Right.

  2. The sweep is in the organization backfill's shape. Read beside backfill-sys-file-organizations.ts: plan / apply / run / format, a system context by default, pageSize and a maxRows ceiling, dry run by default, notes for a scan that could not be made or stopped at its ceiling, failures recorded and never retried, the same engine.find(..., { where, limit, offset, orderBy, context }) and engine.update(object, { id, column }, { context }) spellings. It counts before it writes: the plan reads the whole scope = 'public' population, paged by id, before apply runs, and scanned is that count. It is a no-op at zero: pinned with zero sys_file updates reaching the engine and every row unchanged. It writes one column: the update carries { id, scope: 'user' } and nothing else, so the key keeps its public/ prefix and no byte moves (pinned on the source row after the copy). It is idempotent by construction: every write leaves the predicate, and a second run scans 0 and writes 0 (pinned). user as the target is right: it is copyOwnedFile's own default for a missing scope (file-reference-lifecycle.ts:439) and the upload doors' default for an omitted one (storage-routes.ts:775, :790, :910, :921, :962). Right.

  3. The inverse is named as the rollback, with its precondition. The applied report's rows[] is the rollback list, printed by the formatter with failed rows marked NOT written and a rollback line under an applied run. On this release the engine refuses user back to public as it refuses any undeclared option: pinned. So the inverse is executable only with a code rollback, on the previous release through its engine or as one raw driver statement on the recorded ids; the module docblock, the formatter and the changeset all say so. Right: a data rollback without the code rollback would only recreate the copy-refused state, and the ruling's inverse is kept whole with the one condition it needs stated.

  4. The two ruled pins are present and meaningful. A copy of a rewritten row succeeds: backfill-sys-file-public-scope.test.ts seeds a public row through the driver (the upgrading deployment's state), sweeps it, then writes the file id into a second record's field so the copy-on-claim hook reaches the real copyOwnedFile through installFileReferenceHooks; it asserts the copy lands with scope: 'user', a user/ key, ownership by the new slot, the bytes read from the untouched public/ key, and the source row changed in scope alone. The no-row deployment runs clean: 0 scanned, 0 planned, 0 written, no note, zero engine updates, rows unchanged. The dev's ablation 1 (skip the rewrite) turns the copy pin red at the copy itself; ablation 2 (restore the option) turns only the two refusal pins red. Both pins measure what the ruling asked. Right.

  5. H1, no reader distinguishes public from user: holds, re-measured at this head. Every scope comparison or filter in packages/services/service-storage/src (non-test) compares against attachments alone: storage-routes.ts:457 (the download gate), attachment-lifecycle.ts:103 (the live-attachment predicate) and :663 (the reap guard), stranded-orphan-inventory.ts:290 and :303, verify-file-references.ts:289. copyOwnedFile (file-reference-lifecycle.ts:439) copies the value without branching on it; backfill-file-references.ts:204 writes user. The adapters, metadata-store.ts, attachment-access-hooks.ts and files-to-references-migration.ts read no scope; nothing in packages/, apps/, examples/ or skills/ reads a public/ key prefix or compares a sys_file scope outside this package; sys_upload_session.scope is free text never read back; the scope: 'system' at storage-service-plugin.ts:339 is a manifest scope, not a file scope. At the objectui pin f0268ad784 every .scope === is settings, package manifests, CEL hints or SDUI props, and the upload adapter forwards a caller's scope only. The PR body's line numbers for the two attachment-lifecycle readers (:113, :502) are stale at this head after the main merge; the readers are the same. H1 holds. Right.

  6. H2, the un-swept window, fails closed and the changeset states it exactly. An un-swept row reads, downloads and takes a scope-free update; a copy is refused ERR_FILE_REFERENCE_COPY and leaves no new row (pinned). The REST answer, read rather than probed: FileReferenceCopyError carries a code and no status; in classifyDataError (packages/types/src/data-error-classification.ts) no arm matches it (no structured code, no declared status, no inner message; the text rules look for not-found, missing-column, null-column, missing-table and quoted-object shapes the message does not carry) and the terminal is UNCLASSIFIED_FAULT(): 500 with code: 'INTERNAL_ERROR' and error: 'Internal server error', the sentence logged by logWithheldServerFault and withheld from the wire. sendThrownError and mapDataError share that terminal. The changeset's sentence (500 INTERNAL_ERROR, the sentence in the server log ending Scope must be one of: user, tenant, private, temp, attachments, reads and scope-free updates unaffected) is exact. Acceptable: nothing is lost or widened, only the operator can clear the condition, the code is ledger-registered (error-code-ledger.zod.ts:507), and the population is deployments that stored public rows, which no in-repo or objectui producer ever wrote and no door has accepted since ee8751d41e. Right, as a noted window with the sweep as its close.

  7. Q2: the public surface of @objectstack/service-storage widens by four functions and four types on the package entry. The surface is right: planSysFilePublicScopeBackfill, applySysFilePublicScopeBackfill, runSysFilePublicScopeBackfill, formatSysFilePublicScopeBackfillReport, with SysFilePublicScopeBackfillEngine, SysFilePublicScopeBackfillOptions, SysFilePublicScopeBackfillReport and PlannedSysFileScopeRow, exactly the closure of the operator step's signatures; the three module constants stay off the entry, so no internal is leaked. The entry pin is meaningful: it asserts identity (toBe) between the entry's four functions and the module's, types the operator engine, options and report through the entry's type exports, and drives plan, apply and a second run through the entry; the dev's ablation 3 shows nothing else goes red when an export is dropped, and ablation 4 shows this pin and typecheck do. The widening is the seat's call inside the ruling's shape (still plan/apply, dry run first, nothing at boot, no new command). Right as a surface. ⛔ Its declaration is wrong, judged under ②.

  8. Q3: the step-18 entry text and the regenerated registry. The entry's reason no longer says stored public files are not touched; it names the sys_file option's retirement, the exported sweep (the dry-run and apply functions by name), the rewrite to user, that no access changes (no reader tells the two apart, key and bytes untouched, downloads unchanged), and the pre-sweep copy refusal; the acceptance criteria add the sweep's end state (a dry run scans zero such records). Every sentence is true against the module and the readers above, and the added criterion is what makes the guide's Done-when line unmeetable on a deployment that skipped the operator step, so going one sentence past the rationale is right. registry.ts is consistent with the entry: 409 semantic entries at the merge base, 409 at the head, 409 entry files, and the three distinctive new sentences appear once in the entry and once in the registry; the Migration registry matches its entry files step is green on the head. check-adr-0087-registration accepts the edit with no new marker: its already-registered rule asks only that the named id resolve at HEAD and exist at the merge base (ee8751d41e is an ancestor of 4638625e07), and it reads changesets, not entry edits, so the existing disposition not-required (already-registered storage-scope-public-retired) is the G3 shape. No spec-changes.json or upgrade-guide regeneration is owed since spec(changes): generate the per-major spec-changes section and the upgrade guide at publish; the pull request generates both in memory and renders the diff (#22449 B′, condition 1) #22533, which is in the branch through 5551f8d8d5. Right.

  9. Q1: the tenant-audit census is a faithful projection of the one new write site. The site table in docs/audits/2026-08-tenant-audit-write-call-sites.counts.md gains exactly one row, the sweep's update on sys_file, tenancy enabled, context of undecidable elevation, placed as the organization backfill's row is. The figures that move are the five totals that site touches (241 sites, 160 decidable, 159 tenancy-enabled, 180 threading a context, 105 of undecidable elevation), the two placement rows it lands in (188 readable engine receivers, the sweep's engine interface being the 72nd engine-shaped type recognised; 53 objects named through a const), the prose restatements of those on the page, the measurement stamp, and three unenforced corpus-scale lines. The 18 changed lines on the page and the 10 on the ledger (one added row and nine changed) account for exactly that and nothing else. Right.

② Semver level

  • @objectstack/service-storage: minor. Right twice over: the accept-set narrowing on sys_file.scope ships minor under the launch-window convention (check-changeset-no-major's header), carried by the BREAKING banner and the ADR-0087 disposition, both present; and the additive widening of the package entry takes at least minor (the Check Changeset step's WHICH LEVEL rule: a new exported symbol on an index).
  • @objectstack/spec: patch. Right: a prose edit to an existing migration entry and its regenerated registry; no schema, type or accept set moves.
  • The disposition not-required (already-registered storage-scope-public-retired): right, as judged in ①.8.
  • Clause-②: no (narrowing) is wrong, on both carriers. The line answers 「本卡放宽接受集或扩大公开面吗」, and 公开面 is what the built package's entry declarations reach (execution-duties: not only the re-export list, but at least it). This diff adds four functions and four types to packages/services/service-storage/src/index.ts of a published package: a widening of its public surface by the repo's own example of one (WHICH LEVEL: a new exported symbol on an index). The repo's one reader of the line, scripts/pm/clause2-line.mjs, spells the case of a diff that widens one surface and narrows another as yes (narrowing), and says both facts are then read. no (narrowing) asserts there is no widening, which the seat's own Q2 answer made false: the first half's no was true because it reused INVALID_REQUEST and exported nothing, and the line was carried onto a diff it no longer fits. The gates are silent by design (judgeLevel reads a no as not-declared and exits 0), so nothing mechanical could catch it. The level already satisfies what a yes enforces (a moved package graded minor), so the repair is the declaration alone: PR body line 2 and .changeset/22443-sys-file-public-scope-rewrite.md line 8, both to Clause-②: yes (narrowing). The changeset line ships in CHANGELOG, so it moves with the body, which moves the head. This is the one blocking finding.

③ Boundary flags

From 6090816010 and the seat's answers 6090887049:

  • Q1 census, A: done and faithful (①.9). Q2 export, B: done and right as a surface (①.7); the declaration it changes was not revisited (②). Q3 entry, A: done, true, registry consistent, patch added (①.8).
  • PR line 2: the body now reads no (narrowing); it needs yes (narrowing) (②).
  • The copy-path pin lives in the sweep's test file, not beside file-reference-lifecycle.test.ts: right, it needs the rewritten row and drives the real copyOwnedFile.
  • The pre-sweep copy failure reads as a 500 to the client (noted, not filed): answered in ①.6, acceptable.
  • No UI label for a stored public value until the sweep runs (noted, not measured in the console): a window state the sweep closes and no reader depends on; acceptable as noted.

From 6091717591:

  • The entry's acceptance criterion goes one sentence past the rationale: right (①.8).
  • Census prose "105 more": consistent with the undecidable-elevation total; not a gate-held figure; fine.
  • @objectstack/spec: patch and no new ADR-0087 marker for an edited entry: right (①.8, ②).
  • The committed docs/protocol-upgrade-guide.md and packages/spec/spec-changes.json still carry the old sentence at this head (1 and 2 hits): nothing compares them since spec(changes): generate the per-major spec-changes section and the upgrade guide at publish; the pull request generates both in memory and renders the diff (#22449 B′, condition 1) #22533 and their deletion is spec(changes): delete the committed spec-changes per-major projection and the upgrade guide copy, with their two merge=os-regen routes, once generation at publish has landed (#22449 B′) #22485's; noted, carrier named.
  • The second main merge left registry.ts byte-identical to the text merge: consistent with 409 at base and head and the green registry step.
  • Commit trailers, worktree and push order: process, outside this record.
  • Out of scope, class a: the first half's merged changeset says the stored rows "are not touched". check-empty-changeset refuses any changeset present at the merge base and changed here, by design; it names the DELIBERATE CORRECTION class with the remedy "do NOT restore it, say so on the PR and get it confirmed", so a red-accepted edit is a sanctioned path but costs a non-green check the landing rule would then have to carry as by-design. The seat's disposition (leave it; this PR's changeset corrects it in the same release's CHANGELOG for both packages, and the upgrade guide, the text an upgrading agent greps, is generated from the corrected entry) is acceptable: release prose is outside the contract's three surfaces, the contradiction is corrected beside itself, and the guide is right. Carrier: a docs-only correction after the release, or the DELIBERATE CORRECTION path on a later PR that touches that file for its own reason. Not blocking.

Nothing is escalated beyond the ② finding, which is the seat's to fix in a patch round; every other item is judged right at this head, so the next record is a check that the two declaration lines moved and nothing else did.

Implemented-by: claude/issue-22443-sys-file-public-scope-backfill
Reviewed-by: session_01VZqqwTj2wsihZEbfT6yyYN

VERDICT: FAIL

…d narrowing

The diff adds four functions and four types to the service-storage
package entry, a widening of its published surface, beside the
narrowing of the sys_file scope accept set. The repo's reader of the
line spells that case as yes (narrowing). The levels are unchanged:
service-storage minor already meets what yes requires, spec stays patch.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4d77d54a544d9f9c3d51a4e6b7ec96e34d259fbf
Local-runs: none

PR #22552 (card #22443, ruling 6081131776 letter B, the second half), reviewed again after FAIL 6091871672 on df6619318c6b7269de340049f2fb3bd34803788d, which found one blocking defect: the declaration Clause-②: no (narrowing) on both carriers, on a diff that widens the package entry. The REWORK is 6091885154 (patch round 2) and the dev report is 6092015112. Inputs: the card's body and all 15 comments, the PR body and file list, the net diff from the merge base 4638625e07 at this head, the previous record, and the head's check-runs. Nothing was built, run or re-run.

The hop df6619318c..4d77d54a54 is exactly one line. One commit, trailer naming this session; git diff --stat reads 1 file changed, 1 insertion, 1 deletion: .changeset/22443-sys-file-public-scope-rewrite.md line 8, Clause-②: no (narrowing) to Clause-②: yes (narrowing). The frontmatter (@objectstack/service-storage: minor, @objectstack/spec: patch), the ! summary, the adr-0087 HTML comment with its not-required (already-registered storage-scope-public-retired) disposition, the **BREAKING** banner and every prose section are untouched.

The net diff against main is otherwise what 6091871672 judged, by path and hunk, not by recollection. git diff 4638625e07 df6619318c and git diff 4638625e07 4d77d54a54 are each 1031 lines over the same 13 paths, +757 / −40 on each, matching the PR's file list (13 files, 757 additions, 40 deletions). A diff of the two patches differs at exactly two lines: the changeset's index line (blob ed312ff6ee to 98881e9b62) and its +Clause-② line. Per path, the merge-base-to-head hunks hash identical at the two heads for 12 of the 13 files (tenant-audit-census.mdx, the census counts ledger, backfill-sys-file-public-scope.ts and its test, index.ts, system-file.object.ts, the four objects.generated.ts bundles, 18.storage-scope-public-retired.ts, registry.ts); only the changeset differs. The merge base is unchanged, and main (6a3f82efa7, three commits past it, all landed before 6091871672 was rendered) moved none of the 13 paths except registry.ts, where its hunks (two new step-18 entries, six entries' text) sit at offsets disjoint from this PR's one entry edit; a git merge-tree of the head onto main is clean.

Check-runs on the head at the time of this read: 34 in all; 28 success, 2 skipped (Console Pin Gate and Packed-tarball smoke: a path filter and an opt-in), 4 in progress, 0 failure; the combined commit status is success (1 context). In progress: Test Core 1/6, 3/6, 4/6 and 5/6. These four are not judged green here. Complete and green: Check Changeset, the one job the hop can move, which runs the three changeset gates against this head (check-changeset-no-major with the pull-request payload, so the PR body's line 2; check-adr-0087-registration --base and check-empty-changeset --base, so the changeset file at the head); Lint & Repo Gates, which carries check-tenant-audit-census and the migration-registry match on the merge commit; Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Type Check · workspace, TypeScript Type Check, Build Core, Build Docs, Dogfood Verify CLI, Dogfood Regression Gate 1/3 to 3/3, Temporal Conformance, Test Core 2/6 and 6/6, Spec property liveness, Governed Surface Queue Guard and the PR-shape checks. The four pending shards are placed by the 12 paths that are byte-identical to df6619318c, where the same shards were green (42 complete, 0 failure, per 6091871672); the landing rule, not this record, waits on them.

① Derived judgments

Each of 6091871672's nine judgments is carried or re-judged here. The hop touches no code, so the nine are carried on hunks that hash identical to the ones judged, re-read where a judgment names a line.

  1. sys_file.scope narrows: public leaves the select. Carried: system-file.object.ts and the four translation bundles are the judged hunks. Right.

  2. The sweep is in the organization backfill's shape (plan / apply / run / format, counts before it writes, one column per row, no-op at zero, idempotent, user the target). Carried on an identical hunk. Right.

  3. The inverse is the rollback, with its code-rollback precondition. Carried; the changeset's Rollback section is untouched by the hop. Right.

  4. The two ruled pins (a copy of a rewritten row succeeds; the no-row deployment runs clean). Carried on an identical test hunk. Right.

  5. H1, no reader distinguishes public from user. Carried, and the PR body's citations re-read at this head: storage-routes.ts:457 (file.scope === 'attachments' || fieldOwned), attachment-lifecycle.ts:103 (the committed-attachment predicate) and :663 (the reap guard), stranded-orphan-inventory.ts:290 and :303, verify-file-references.ts:289, file-reference-lifecycle.ts:439 (copyOwnedFile's user default), storage-routes.ts:388 (the upload gate reading the request). Every scope comparison in service-storage's non-test source still compares against attachments alone. The two line numbers 6091871672 called stale (:113, :502) now read :103 and :663, and both are right at this head. Right.

  6. H2, the un-swept window fails closed and the changeset states it exactly. Carried; the changeset's operator-step section is untouched. Right.

  7. Q2: the entry widens by four functions and four types. Carried as a surface: the index.ts hunk is identical, and it is a widening of a published package's entry (its own comment says so: exported, unlike the organization backfill beside it, because a deployment consumes the package, not a checkout). The declaration this widening demands is now right, judged under ②.

  8. Q3: the step-18 entry text and the regenerated registry. Carried on identical hunks for both files. The registry-match step runs in Lint & Repo Gates on the merge commit, which is complete and green on this head; the hop touches neither file. Right.

  9. Q1: the census is a faithful projection of the one new write site. Carried on identical hunks; main has not moved the census files or service-storage source since the merge base. Right.

  10. The hop is the repair 6091871672 asked for and nothing else. One line, the one named; the matching body edit is judged at 11. Right.

  11. The PR body moved as the REWORK said, and only so. Against the body 6091871672 read (the snapshot taken with the PR at df6619318c), four content lines changed: line 2 (no (narrowing) to yes (narrowing)), line 17 (the Changeset bullet now reads yes (narrowing) with the reason: the entry export widens the package surface while the option retirement narrows the stored vocabulary), and lines 28 and 29 (the two attachment-lifecycle.ts citations, to :103 and :663); plus two trailing blank lines. Nothing else in the body moved. Its file count and line counts (13 files, +757 / −40, 797 changed lines, numstat against 4638625e07) still match the diff. Right.

  12. The ruling is quoted as it was said, and the measured value departs from it with the departure stated. The body's blockquote is the ruling's letter-B paragraph verbatim with its bold stripped, cut at contract review before the queue.; it carries the ruling's words Clause-②: no (narrowing) inside a backtick span mid-sentence. readClause2Line never reads that line as a declaration: the key is not line-initial after decoration (the line opens with the blockquote marker and B —), so it is at most an inline-key residue, and the scan has already returned line 2 as declared. Line 2 is the measured value. The departure is stated where the claim's declaration lives: the REWORK 6091885154 amends the card's Clause-② to yes (narrowing) and supersedes the Clause-②: no of claim 6089203182, naming the cause (the line was written before seat answer Q2 in 6090887049, which exported the sweep), and the dev report 6092015112 flags the departure from the ruling's words. The body states the reason beside its own value (line 17). Acceptable: the maintainer's 「同意」 was to letter B's substance (retire the option, rewrite the rows, the pins, the rollback); the export is the seat's call inside that letter, judged right at ①.7; the declaration line is a measurement the two CI gates read off the diff as it is, and 6091871672 found no false of this diff. A measured line cannot keep a value a prediction gave it before the diff existed. Nothing in the ruling turns on the direction word: the grade it implies (minor, launch window) is the grade the ruling's own no (narrowing) implied. Right. One nit, not blocking: the body does not say, in one sentence beside the quotation, that line 2 departs from it; the card carries that sentence.

② Semver level

  • The declaration Clause-②: yes (narrowing) is right, on both carriers. scripts/pm/clause2-line.mjs:93 spells a diff that widens one surface and narrows another as yes (narrowing), both facts true and both read. This diff does both: the entry gains four functions and four types (①.7) and sys_file.scope loses an option (①.1). Both carriers hold the identical bytes (changeset line 8 and PR body line 2 compare equal under od: the key, a colon, the token, the arm in parentheses, nothing else on the line). Read through the reader by hand: CLAUSE2_KEY_LINE matches with no backtick on the key, so the describing tell cannot fire; matchValueToken takes yes with no alternation after it; readArmToken takes narrowing from the parenthesis; the contradiction check is no beside widening only. So { kind: 'declared', value: 'yes', arm: 'narrowing' } on both. The gates agree on this head: Check Changeset is green, and its level axis reads the PR body through this same reader.

  • The levels satisfy what yes requires. judgeLevel (check-changeset-no-major.mjs) refuses a yes only when a moved package is graded patch and no moved package is graded minor or above. Both packages move packages/**/src/** here. @objectstack/service-storage: minor is the raised package, which discharges the axis for the PR; @objectstack/spec: patch is the residual the gate prints and sets aside (verdict discharged, exit 0) and leaves to this record. minor on service-storage is right twice over, as before: the accept-set narrowing ships minor under the launch-window convention, and a new exported symbol on an index takes at least minor. patch on spec is right: a prose edit to an existing migration entry and its regenerated registry; no schema, type or accept set moves. Both carried from 6091871672 ②, now with the declaration that matches them.

  • The arm keeps the breaking-ness declared. check-adr-0087-registration's breakingDeclaration fires on the **BREAKING** banner, the ! summary and a Clause-② arm reading narrowing; yes (narrowing) keeps the third signal that a yes (widening) would have dropped. The disposition not-required (already-registered storage-scope-public-retired) is untouched and still the right shape (①.8, carried). Check Changeset is green on this head with that gate in it.

  • The changeset line ships in CHANGELOG; it now ships true.

③ Boundary flags

Carried from 6091871672 ③, each re-read against the hop:

New at this head:

  • The claim's own line reads Clause-②: no (6089203182, no arm). The REWORK 6091885154 amends it on the card to yes (narrowing) and says the claim is otherwise unchanged; the double-carrier pair check that once compared claim and PR is retired (ruling 5770886272, per clause2-line.mjs), so nothing mechanical reads the claim line against the PR. Consistent; nothing owed.
  • The ruling's verbatim words and the measured value differ (①.12): stated on the card, reasoned in the body, acceptable. If the maintainer wants the ruling text itself amended, that is the director seat's act, not this PR's.
  • Four Test Core shards are in progress on this head (1/6, 3/6, 4/6, 5/6). They read the 12 paths the hop did not touch and were green at df6619318c on the same main; this record does not call them green, and the landing waits on them.
  • The PR is a draft (mergeable_state: blocked). Process, outside this record.
  • main moved registry.ts after the merge base (two step-18 entries added, six entries' text) in hunks disjoint from this PR's; the merge is clean, and the registry-match step, run on the merge commit, is green on this head. Nothing owed.

Nothing is escalated. The one blocking finding of 6091871672 is repaired by the one-line hop and the matching body edit; every other judgment is carried on hunks that hash identical to the ones judged.

Implemented-by: claude/issue-22443-sys-file-public-scope-backfill
Reviewed-by: session_01VZqqwTj2wsihZEbfT6yyYN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 10, 2026 01:31
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 10, 2026 01:31
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit 96e4be4 Oct 10, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22443-sys-file-public-scope-backfill branch October 10, 2026 02:04
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

2 participants