Skip to content

feat(spec,service-storage,client)!: one upload-scope vocabulary for the upload requests, the sys_file select, the upload doors and the SDK (#22470) - #22647

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22470-upload-scope-vocabulary
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22470-upload-scope-vocabulary

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22470
Clause-②: yes (narrowing)

One upload-scope vocabulary, declared once in @objectstack/spec and read by the two upload requests, the sys_file scope select, the two upload-start doors and the SDK's storage.upload. A scope outside it is now a caller error, answered 400 INVALID_REQUEST naming the allowed values, instead of the data engine's invalid_option relayed as 500 INTERNAL with a message telling the operator to restore the data engine.

Direction: triage 6080435724 as amended by 6081565553 (the vocabulary is the sys_file select, not StorageScopeSchema), standing per 6092602285; the client half ruled option A in seat answer 6095219171.

What changes

  • @objectstack/spec: new UploadScopeSchema and type UploadScope beside the upload request schemas (api/storage.zod.ts, exported from @objectstack/spec/api): user, tenant, private, temp, attachments. GetPresignedUrlRequestSchema.scope and InitiateChunkedUploadRequestSchema.scope read it; the user default and the description are kept. StorageScopeSchema is not touched.
  • @objectstack/service-storage, SystemFile: the scope select builds its options from UploadScopeSchema.options, in the enum's order. The labels and the comment explaining attachments stay local, in a label map keyed by UploadScope, so an enum member added without a label fails tsc. The stored values and labels are unchanged (pinned byte-equal to the old literal list).
  • @objectstack/service-storage, registerStorageRoutes: the one scope gate both upload-start handlers already asked (requireAcceptedUploadScope, from the public retirement) now asks UploadScopeSchema.safeParse and refuses everything else: any string, a case variant, null, a number. It runs before the size gate, before any row, URL or backend call. One gate, one status, one code. The former public-only refusal is folded into it rather than left beside it, keeping its message family and, for public, its acl 'public_read' remedy. An omitted scope is still the default user, and a real engine fault still answers 500.
  • @objectstack/client: storage.upload types its scope parameter UploadScope instead of string (one parameter type, one type import). getPresignedUrl and initChunkedUpload take the request types and narrow with them.
  • ADR-0087: D3 entry upload-request-scope-closed, registry.ts regenerated. Changeset: spec minor, client minor, service-storage patch, registered disposition. No spec-changes.json or upgrade-guide regeneration is owed: check:spec-changes and check:upgrade-guide project in memory and are green.
  • Generated: the api-surface, export-origins, declaration-map and json-schema.manifest shards for api, and the two reference pages under content/docs/references (gen:docs). type-alias-convention.pin.test.ts gains the isomorphic pin for UploadScopeSchema (773 → 774, with its receipt), which check:spec-parsed-alias reads as its registry.

Evidence (head 3e8227f5df, after merging origin/main at 1b99388505 through os-regen-merge.sh)

All runs under os-verify-lock.

  • New pins. packages/spec/src/api/storage.test.ts: the enum lists the five in order and refuses public. Each request accepts all five, keeps the user default, and refuses avatars, public, User and the empty string at parse, with issue code invalid_value on path scope. packages/services/service-storage/src/upload-scope-vocabulary.test.ts runs on a real ObjectQL over SqlDriver (sqlite in memory) with the real SystemFile and SystemUploadSession:
    • the select's values equal the enum's, in order, and the labels are unchanged;
    • scope: 'avatars' on each door → 400 INVALID_REQUEST, the message names the five values, and there are zero sys_file rows, zero session rows, no presign and no backend initiate call;
    • null, 7 and User → 400;
    • control: every allowed scope, and an omitted one, → 200, and the engine stores it;
    • control: an engine insert fault on scope user → 500 INTERNAL.
  • Suites.
    • spec --project local: 642 files, 19157 passed, 1 todo.
    • spec --project repo: 54 files, 915 passed.
    • service-storage: 50 files, 837 passed.
    • client: 51 files, 653 passed.
    • typecheck exit 0 for spec, service-storage and client.
    • full workspace build: 72 of 72 tasks.
  • Ablation (scripts/ablation-replace.mjs, wrap mode with trap restore, at 3e8227f5df). The refusal condition in requireAcceptedUploadScope is replaced by return true: anchor 1 → 0, marker 0 → 1 → 0.
    • Green leg: 54 of 54.
    • Mutated leg: 3 failed, 51 passed. The avatars pin goes red with expected 500 to be 400, which is the card's defect reproduced over the real engine. The null / number / case pin goes red with expected 200 to be 400, and the public pin goes red.
    • Restored to blob b355ea0a5b == HEAD, with git diff HEAD empty.
    • No build leg is owed: the pins import the door from source by relative path.
  • Reverse verification of the type change. Before the client edit, @objectstack/client typecheck exited 2 with TS2322 (string not assignable to the five-value union) in storage.upload, and @objectstack/client#build failed its DTS step. So the rebuilt spec declarations were what the consumer read. After the edit it exits 0.
  • Gates. dispatch-gates --commands on the final diff derives 119 commands. All 119 ran at 3e8227f5df with exit 0, including check:dts-closure, check:skill-examples, check:dual-build-cjs-loads, check:i18n (service-storage: 7 bundles in sync) and check:type-check-debt. --ran: 119 derived, 119 run, 0 NOT MEASURED, 0 UNRUN. check:generated: 15 of 15 up to date after the merge.
  • Not run locally: integration layers and the repo-wide lint, which CI owns.

Reach (measured)

  • In-repo door callers. The SDK storage.upload (default user), two README examples passing user, and the dogfood upload sites: 7 send attachments and 2 send user. The service-storage and organizations tests send only vocabulary values. Nothing sends an off-vocabulary scope through a door. examples/** and apps/**: 0.
  • objectui, at the pin 20c6d351ad and at main 12ff256313: 2 adapter callers. The console sends no scope, and the record attachments panel sends attachments. There are 0 calls to the SDK upload and 0 imports of the request types, so the Console Pin Gate is unaffected.
  • Console bundle: not measured (no packages/console/dist in this checkout).

Acceptance notes

  • The doors now also refuse an explicit null scope, which used to be read as the default user. This agrees with the published schema, which refuses null at parse. It is pinned, and ruled to stand in 6095219171. No measured caller sends null.
  • No STEP18_RATIONALE fragment, which was ruled not owed.
  • sys_upload_session.scope stays a free text column. It mirrors the file row's scope, and only the chunked door writes it, after the gate. Noted, not filed.
  • The contract review is owed before the queue.

Generated by Claude Code

…ad requests, the sys_file select and the upload doors

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
… the UploadScope export reaches; add the changeset

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…in the upload-scope pins

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

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/client, @objectstack/service-storage, @objectstack/spec, touching 24 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/declaration-map/api.json, packages/spec/export-origins/api.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

17 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 5fb1746611439610b81d4a44846c22de15f90d6f.

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

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/declaration-map/api.json, packages/spec/export-origins/api.json, …) — pages documenting those are invisible to this run
  • 9 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 — 144 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 5fb1746611439610b81d4a44846c22de15f90d6f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5fb1746611439610b81d4a44846c22de15f90d6f

⚠️ 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 5fb1746611439610b81d4a44846c22de15f90d6f → 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: 3e8227f5dff1d1d8ac99ae1c53febc80a9a8ffdb
Local-runs: none

Inputs: card #22470 (body; comments 6080435724, 6081565553, 6092602285, 6094375674, 6095201368, 6095219171, 6095792615), PR #22647 (body, 16-file list, net diff against merge-base 1b99388505 at the head above), the head's check-runs. The branch had not moved: git rev-parse on the fetched ref is the head above. Diff read from the checkout, GitHub read with gh api only.

① Derived judgments

  • UploadScopeSchema / UploadScope (new export) — right. Declared once in packages/spec/src/api/storage.zod.ts as a lazySchema over z.enum(['user','tenant','private','temp','attachments']), the sys_file order, with its own describe. UploadScope = z.input matches the file's and StorageScope's convention; a bare enum is isomorphic, so no UploadScopeParsed is declared and the ADR-0122 pin is added instead (773 → 774, Iso_api_storage__UploadScopeSchema). Reaches @objectstack/spec/api through the existing export * from './storage.zod'. Present in api-surface/api.json as UploadScope (type) and UploadScopeSchema (const), the shape precedent 72af58c621 set.
  • Both request schemas' scope — right. GetPresignedUrlRequestSchema.scope and InitiateChunkedUploadRequestSchema.scope read UploadScopeSchema.default('user').describe(UPLOAD_SCOPE_DESCRIPTION): the default and the description are the ones on main. Pins (storage.test.ts): each request accepts the five, keeps the user default, refuses avatars, public, User and the empty string at parse with invalid_value at path ['scope']; the enum refuses public.
  • SDK storage.upload (option A) — right. packages/client/src/index.ts moves exactly one parameter type (scope: UploadScope = 'user') and adds UploadScope to the existing value-less import from @objectstack/spec/api; nothing else in the package. getPresignedUrl(req: GetPresignedUrlRequest) and initChunkedUpload(req: InitiateChunkedUploadRequest) (head lines 5262, 5274) narrow through the request types with no edit, as the changeset says.
  • The sys_file select — right. options: UploadScopeSchema.options.map((value) → ({ label: UPLOAD_SCOPE_LABELS[value], value })); the labels are local in a Record UploadScope → string, so a member added to the enum without a label fails tsc. Values in enum order user, tenant, private, temp, attachments; labels User, Tenant, Private, Temp, Attachments unchanged; the attachments comment kept. Pinned byte-equal to the old literal list. Reading .options through the lazySchema Proxy is the pattern production code already uses (ChartTypeSchema.options in lint, MetadataLockSchema.options in metadata-protocol); the Proxy's get trap returns the real instance's property.
  • The two doors' 400 — right. requireAcceptedUploadScope(scope, res): scope === undefined || UploadScopeSchema.safeParse(scope).success passes, everything else is sendError(res, 400, 'INVALID_REQUEST', message) where the message names UploadScopeSchema.options.join(', '). Both upload-start handlers call it after the required-fields check and before the size gate, the pending sys_file row, the key and URL, the backend initiateChunkedUpload, and the sys_upload_session row. One gate, one status, one code; the former public-only constant and its separate message are gone, not left beside it. The 500 path is untouched.
  • public — right. Folded into the one gate; for scope === 'public' the message keeps the acl 'public_read' remedy sentences. The storage: the public storage scope is described as "publicly accessible static assets", but after PR #22439 a default-acl file with that scope needs a signed-in caller — trim the value or enforce it #22443 pins in the UNCHANGED storage-routes.test.ts assert ^scope 'public' is not accepted and contain acl 'public_read' (lines 744–745), both satisfied by the new text, so they pass with no edit.
  • One list — right. No second production copy of the five values: the literal lists that remain are test pins (the two new test files, deliberate, and the pre-existing storage-routes.test.ts:756 from storage: the public storage scope is described as "publicly accessible static assets", but after PR #22439 a default-acl file with that scope needs a signed-in caller — trim the value or enforce it #22443), the D3 entry and changeset prose, and the generated reference page. StorageScopeSchema (system/object-storage.zod.ts) is not in the diff.
  • The pins over a real engine — right. upload-scope-vocabulary.test.ts runs a real ObjectQL over SqlDriver sqlite :memory: with the real SystemFile and SystemUploadSession: avatars on each door → 400 INVALID_REQUEST, the message opens scope 'avatars' is not accepted and contains The upload scopes are user, tenant, private, temp, attachments, zero sys_file rows, zero session rows, the getPresignedUpload and initiateChunkedUpload spies never called; null, 7, User → 400 on both doors, zero rows; control: the five and an omitted scope → 200 on both doors, stored as sent or user; control: an engine insert fault on user → 500 INTERNAL. The ablation is read off the report (not re-run): gate condition → return true, mutated leg 3 red of 54 — avatars with expected 500 to be 400 (the card's defect over the real engine), the null/number/case pin, and the public pin — restored to blob b355ea0a5b == HEAD.
  • ADR-0087 — right. D3 entry upload-request-scope-closed: surface names both request fields and carries no backticks or pipes; replacement, reason and acceptanceCriteria state what the diff does. registry.ts carries it in the sort-by-id position build-migration-registry.ts emits (ui-report-joined-container-selection-refused before it, view-chart-binding-dataset-required after), file 18.upload-request-scope-closed.ts. "No D2 conversion" is right: no metadata type under packages/spec/src carries the upload request or an upload scope (grep outside api/storage* and migrations/: 0 hits), so there is no authored source for os migrate meta to rewrite. The changeset carries the marker adr-0087: registered upload-request-scope-closed.
  • Generated shards and pages — generated. api-surface, export-origins, declaration-map and json-schema.manifest insert the two names at their alphabetical positions and nothing else moves; references/api/storage.mdx and references/index.mdx carry the AUTO-GENERATED header, the scope rows render Enum 'user' | 'tenant' | 'private' | 'temp' | 'attachments', and the counts move 1514 → 1515 and API 431 → 432 by exactly the one schema.
  • null. On main the door did not parse the request: scope !== 'public' passed null and scope ?? 'user' stored it as user, while the published schema (z.string().default('user')) already refused null at parse. The head moves the door to the published contract. A wire caller sending scope: null sees 200 → 400; measured: no in-repo caller, no objectui caller (the adapter drops an undefined scope). The changeset lists null among the refused values. See ③ for the residual.
  • SDK reach. At head, three documentation call sites pass a scope, all 'user': packages/client/README.md:299, packages/services/service-storage/README.md:177, content/docs/api/client-sdk.mdx:504 (the report counts two; the third changes nothing). objectui re-measured at the pin 20c6d351ad (.objectui-sha at head) and at objectui origin/main 023f00d46b (past the 12ff256313 the report measured): 0 files reference storage.upload, the two request types or UploadScope; the adapter sends attachments from RecordAttachmentsPanel and the console sends no scope. No hand-written objectstack page teaches a free-string scope; that docblock lives in objectui's adapter, owned by objectui#12055.

② Semver level

③ Boundary flags

  • packages/client/src/index.ts outside the claim surface: ruled option A in 6095219171; the diff is the one parameter type and one import the ruling named. Answered.
  • The regenerated shards, the two reference pages and the type-alias-convention.pin.test.ts receipt: ruled covered in 6095219171; verified generated above. Answered.
  • The null refusal: ruled to stand in 6095219171; pinned. Residual (not a breach): the changeset names null among the refused values but does not say the door formerly tolerated it, and the FROM → TO table has no row for it; the one-line fix ("or none") covers the caller. One sentence in the changeset would close it; the seat's call, not a blocker.
  • No STEP18_RATIONALE fragment: ruled not owed. Answered.
  • The round-1 probe edit of the client file: not in the diff; restored to blob == HEAD per the report. Answered.
  • origin/main merged at 1b99388505 through os-regen-merge.sh; registry.ts regenerated byte-equal; check:generated 15 of 15. Answered.
  • sys_upload_session.scope stays free text: noted by the dev, not filed; only the chunked door writes it, after the gate. Outside this card.
  • The PR is a draft. The dev's report records pr_create draft; marking it ready is the seat's act on green.
  • Check-runs on this head at the last poll: 32 total — 20 success, 2 skipped (Console Pin Gate, Packed-tarball smoke), 10 in progress (Test Core 1–6, Dogfood Regression Gate 2/3 and 3/3, Lint & Repo Gates, Type Check · workspace). The 10 are not judged green; their conclusions are the gate verdicts and the queue waits on them. Nothing in them is pre-judged by this record.

Nothing is escalated.

Implemented-by: claude/issue-22470-upload-scope-vocabulary
Reviewed-by: session_01VZqqwTj2wsihZEbfT6yyYN

VERDICT: PASS

Read and polled at 2026-10-10T08:55Z.

…load-scope-vocabulary

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
The os-regen driver kept the branch's side of content/docs/references/index.mdx;
main's side is restored and the page regenerated, so it counts both main's
new data schema and this branch's UploadScope (1516 schemas).

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 10, 2026 10:09
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 10, 2026 10:09
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit 6a3fe25 Oct 10, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22470-upload-scope-vocabulary branch October 10, 2026 10:38
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