Skip to content

fix(service-storage): a file field's accept/maxSize refusal answers 400 ERR_FILE_CONSTRAINT - #21751

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21730-file-constraint-400
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21730-file-constraint-400

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #21730
Clause-②: no

What changed

FileConstraintError (packages/services/service-storage/src/file-reference-lifecycle.ts) declares status = 400, as FileFieldBulkWriteError in the same file already does. It also carries field and constraint ('accept' | 'maxSize') as members, set by the constructor, and both throw sites in assertFileConstraints pass them. The constructor is now new FileConstraintError(field, constraint, message), and the FileConstraint type is exported beside the class. The message text is byte-identical.

A file field's accept / maxSize refusal now answers 400 ERR_FILE_CONSTRAINT with a sentence naming the field and the constraint. Before, it answered the sanitised 500 INTERNAL_ERROR. The accept set does not change: the same files are refused, and a refused write still persists no row and claims no file.

Measured at the /api/v1/data door

Real stack (bootStack + StorageServicePlugin), object ffc_doc with doc: { type: 'file', accept: ['.pdf'], maxSize: 10 }, sys_file rows seeded under a system context.

write before (ff29410e) after (415514d0)
POST, text/plain file 500 {"error":"Internal server error","code":"INTERNAL_ERROR"} 400 {"error":"File type 'text/plain' is not permitted by the accept list declared for 'doc' (.pdf)","code":"ERR_FILE_CONSTRAINT","object":"ffc_doc"}
POST, 5005-byte PDF 500, same body 400 {"error":"File exceeds the maximum size declared for 'doc' (5005 bytes > 10 bytes)","code":"ERR_FILE_CONSTRAINT","object":"ffc_doc"}
PATCH onto the 5005-byte PDF 500, same body 400, same body as the POST
POST, 6-byte PDF (control) 201 201

Read-back on both sides: neither refused POST wrote a row, and no refused write claimed its sys_file (ref_object / ref_id / ref_field stay null).

The PM's mechanism hypotheses, measured

  • H1 holds. ERR_FILE_CONSTRAINT is registered at packages/spec/src/api/error-code-ledger.zod.ts:506. No ledger edit.
  • H2 holds. The 400 body carries exactly the keys error, code and object. field and constraint are declared on the error, but the declared-status passthrough (packages/types/src/data-error-classification.ts, 4xx arm of classifyDataError) forwards only the bounded message, the narrowed code and the route's object. The exported mapDataError wrapper can add a producer-marked userMessage, and this error sets none. So on the wire, the field and the constraint are named by the message only. Triage's direction asked for members "the thrown-error resolver forwards". That half is not met, because no such member exists without a packages/types edit, and this card was fenced away from that file. See Acceptance notes.
  • H3 holds. Both messages reach the body byte-identical (error === thrown message for accept, maxSize and the update). The 4xx arm strips only a leading restatement of the producer's own code, which this message has none of. It then applies the 500-character truncation bound. No leak heuristic runs on this arm.
  • H4 holds. Create and update reach the same throw site (applyCopyOnClaim, called from both beforeInsert and beforeUpdate). Both are pinned, at the unit seam and at the door.

Tests

  • packages/services/service-storage/src/file-reference-lifecycle.test.ts: the accept and maxSize create refusals and a new update refusal each assert { code: 'ERR_FILE_CONSTRAINT', status: 400, field: 'image', constraint }, that the message names the field, and that no row was written or file claimed. 51/51 pass. The whole @objectstack/service-storage suite passes 634/634 (41 files), and typecheck (including check:test-typecheck) exits 0. Both at 9251e230.
  • packages/qa/dogfood/test/file-field-constraint-refusal.dogfood.test.ts (new): the door-level pin from the table above, in 5 cases. Both refusals, the update refusal, the small-PDF control, and an update-onto-a-permitted-file control. Each refusal asserts code and status and reads back the rows and the sys_file ownership columns. 5/5 pass, at 415514d0 (the main merge that followed brought only packages/cli files). The dogfood tsc --noEmit exits 0 and its program lists this file.
  • Ablation (committed fix first, so restore points at HEAD). I planted readonly ablation21730 = true; in place of readonly status = 400; on FileConstraintError, using scripts/ablation-replace.mjs (anchor 1 → 0, blob 632a803a → 487a00f9). Then I rebuilt service-storage. ablation-dist-preflight proved the marker present in 2 built files. Results:
    • Unit: 3 failed / 48 passed. The three refusal cases failed with expected Error: File exceeds the maximum size decl… to match object { code: 'ERR_FILE_CONSTRAINT', …(3) }.
    • Dogfood: 3 failed / 2 passed. The three refusals failed with {"error":"Internal server error","code":"INTERNAL_ERROR"}: expected 500 to be 400. Both controls stayed green.
    • Restore: blob after restore 632a803a equals HEAD, git diff HEAD is empty, the whole-tree git status --porcelain is empty, and the rebuild's preflight in --absent mode shows the marker absent from all 6 built files. The unit (51/51) and dogfood (5/5) re-runs passed.
    • The ablation exercised the status assertions. The no-row read-backs were not ablated separately, because no product mutation answers 400 and lands the row. What shows the readers can see a landed write is the control cases: the same rowsNamed / owner readers return the row and its claimed file.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 9251e230 derived 70 commands. I ran every one, recording each exit code. --ran reports: 70 derived famil(ies) accounted for — 68 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).

  • NOT MEASURED: pnpm check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET, because 33 packages outside this diff's closure have no dist/. Supplementary reading: require() of service-storage/dist/index.cjs and import() of dist/index.js both load, and each constructs a FileConstraintError with status: 400 and both members.
  • NOT MEASURED: pnpm check:i18n, reason: PREREQUISITE NOT MET (unbuilt config imports). This diff touches no translation bundle or label.
  • node scripts/check-plugin-teardown-shape.mjs --self-test first exited 3, because its pinned fixture commit was absent from this shallow clone. It exited 0 after git fetch --depth=1 of that commit.

Acceptance notes

  • QA checklist clause, routed to the next run, not edited here. docs/qa/platform-checklist/areas/attachments-storage.json, item attachments-storage.field-accept-maxsize-server-enforced, carries a knownGap and clause 2. Both predict the sanitised 500 and say a measured 4xx carrying ERR_FILE_CONSTRAINT means "revise this clause with the measurement". The FOLLOW-UPS.md row D13 describes the same 500. The item's own clause routes that revision to the next QA run. The message prose that clause greps ('exceeds the maximum size declared for', 'not permitted by the accept list declared for') is unchanged.
  • The field and the constraint are on the wire through the message only. That is what the door forwards today (H2). Forwarding field / constraint as structured body members would take a packages/types change (an ERR_FILE_CONSTRAINT arm in structuredCodeAnswer, or a generic declared-4xx detail channel). That file belongs to another lane, so the seat routes it. Nothing measured here reads such a member.
  • Observation, not filed: a declared-4xx refusal at the data door still prints [REST] Unhandled error with a stack (logUnexpectedRouteError treats only 403/404/409/502/503 and a few query codes as expected). This refusal shares that with FILE_FIELD_BULK_WRITE_REFUSED and every hook-declared 400. It is log noise only, and the wire is unaffected. Carrier: none.
  • Observation, not filed: content/docs/data-modeling/field-types.mdx (Media Types) says file constraints "are configured at the storage-provider layer, not on the field". accept / maxSize have been declared on FieldSchema and enforced on write since 17.0. This was already false before this change, which does not touch it. Carrier: none.
  • The changeset is patch. The exported FileConstraintError constructor gains two leading parameters. The only constructor in the repository is the module's own throw sites.

Patch round 1

Appended by the seat (domain:services#1, session_011K3zqE8Pv1Evw5hc8tZCnN) from the dev's patch-round report 5981408643, after seat review 5981327964.

  • content/docs/data-modeling/field-types.mdx, "Media Types" paragraph (f18345a4). The sentence saying file constraints "are configured at the storage-provider layer, not on the field" is replaced. The paragraph now says that accept and maxSize apply to the media family (file, image, avatar, video, audio), and what each one means, following the spec's own .describe() text: accept is the permitted upload types as MIME types or extensions, offered to the file picker and enforced on write. maxSize is the maximum file size in bytes, enforced on write against the stored file size. It also says a create or update whose referenced file violates either is refused with 400 ERR_FILE_CONSTRAINT, and that the message names the field and the constraint. It keeps the true half: storage itself is configured at the storage-provider layer. No other sentence on the page mentions accept or maxSize, so nothing else on the page changed.
  • main merged at aa33b2e3. It brought 5 commits, and none of them touches this PR's files.
  • Gates at aa33b2e3. dispatch-gates --commands derives 95 commands. The doc edit adds 25 of them, and the dev ran those 25 plus check:nul-bytes: all exited 0. The other 70 codes are the code-half results recorded at 9251e230, reused as recorded. --ran reports: 95 derived, 93 run, 2 NOT-MEASURED (check:dual-build-cjs-loads and check:i18n, each exit 3 with its prerequisite not met, unchanged from the code half), 0 UNRUN.
  • Acceptance notes revision: the field-types.mdx observation above is resolved by this round.

Generated by Claude Code

claude added 2 commits October 4, 2026 14:12
…00 ERR_FILE_CONSTRAINT

FileConstraintError declared a registered code but no status, so the data
API's declared-status passthrough never fired and the refusal left as a
sanitised 500 INTERNAL_ERROR with the field and reason withheld. It now
declares status 400, as FileFieldBulkWriteError in the same module does,
and carries the field and the violated constraint as members.

Unit pins cover create (accept, maxSize) and update; a dogfood pin drives
the real /api/v1/data door for both refusals, the small-PDF control, the
update refusal and its control, with read-back of rows and file ownership.

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

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-storage, touching 4 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.

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

  • content/docs/data-modeling/field-types.mdx (via maxSize (literal, a string literal in FileConstraint; a string literal in assertFileConstraints))
  • content/docs/data-modeling/validation-rules.mdx (via maxSize (literal, a string literal in FileConstraint; a string literal in assertFileConstraints))
  • content/docs/kernel/contracts/cache-service.mdx (via maxSize (literal, a string literal in FileConstraint; a string literal in assertFileConstraints))
  • content/docs/plugins/packages.mdx (via maxSize (literal, a string literal in FileConstraint; a string literal in assertFileConstraints))
  • content/docs/protocol/kernel/metadata-service.mdx (via maxSize (literal, a string literal in FileConstraint; a string literal in assertFileConstraints))
  • content/docs/ui/actions.mdx (via maxSize (literal, a string literal in FileConstraint; a string literal in assertFileConstraints))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/index.mdx (via maxSize (literal, a string literal in FileConstraint; a string literal in assertFileConstraints))
  • content/docs/releases/v16.mdx (via maxSize (literal, a string literal in FileConstraint; a string literal in assertFileConstraints))
  • content/docs/releases/v17/17-0.mdx (via FileConstraintError (symbol, a top-level class), maxSize (literal, a string literal in FileConstraint; a string literal in assertFileConstraints))
  • content/docs/releases/v17/17-4.mdx (via maxSize (literal, a string literal in FileConstraint; a string literal in assertFileConstraints))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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
  • 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 — 7 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 ea7ff394b6d802de9ad0448130bef86d4407760d → packageMentionDocs.

Which tree this was computed on

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

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

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

claude added 2 commits October 4, 2026 15:00
… with 400 ERR_FILE_CONSTRAINT

The Media Types paragraph said file constraints are configured at the
storage-provider layer, not on the field. FieldSchema declares accept and
maxSize for the media family and the storage service enforces them on
write, so the paragraph now states both properties in the spec's own terms
and the 400 ERR_FILE_CONSTRAINT refusal, keeping the true half: storage
itself is configured at the storage-provider layer.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 4, 2026 15:32
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 4, 2026 15:32
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 33f9791 Oct 4, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21730-file-constraint-400 branch October 4, 2026 16:09
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…cision in words instead of a tracker number (stage 13) (objectstack-ai#21763)

Part of objectstack-ai#20749
Clause-②: no

Stage 13 of this card, and the fourth area of class (e): the test
strings shipped under `packages/spec/src`, as ruled in `5902360492` on
objectstack-ai#20513. This stage takes two whole directories, `contracts/` and
`conversions/`. Their 97 test-title and test-string literals carried 108
tracker ids citing 78 records. 107 ids in 96 literals now either state
what their record decided, in words (form D), or are dropped where the
title already says it. One id stays, for the reason given below. Text
only: no assertion, identifier, test count or code comment changes.

## Census at the base (`866b4393d0`, the claim's base)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`) and
`census-wide.cjs` (md5 `c98410a19529c439adb0afbfb00026a2`),
byte-identical to the copies stages 10 to 12 used. A literal counts as a
test title when its folded message is argument 0 of a `describe` / `it`
/ `test` call, `.each` / `.skip` / `.only` chains included. Everything
else is an "other" string.

Both instruments read **1509 messages / 1606 ids in 348 files at the
base**, which is stage 12's head reading exactly. `contracts/` reads 63
/ 74 and `conversions/` 34 / 34, also stage 12's figures.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `data/` | 95 | 468 / 501 | 445 / 475 | 23 / 26 |
| `ui/` | 81 | 392 / 415 | 374 / 397 | 18 / 18 |
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| **`contracts/`** (this PR) | 25 | **63 / 74** | 59 / 70 | 4 / 4 |
| **`conversions/`** (this PR) | 9 | **34 / 34** | 34 / 34 | 0 |
| `security/` | 8 | 28 / 28 | 28 / 28 | 0 |
| `ai/` | 9 | 18 / 20 | 13 / 15 | 5 / 5 |
| `identity/` | 6 | 15 / 15 | 14 / 14 | 1 / 1 |
| `integration/` | 4 | 14 / 14 | 13 / 13 | 1 / 1 |
| `migrations/` | 2 | 9 / 12 | 9 / 12 | 0 |
| `marketplace/`, `meta-spelling/`, `studio/` | 5 | 7 / 7 | 7 / 7 | 0 |
| **total** | **348** | **1509 / 1606** | **1422 / 1515** | **87 / 91**
|

- **Controls.** Lit, a title: `contracts/data-engine.test.ts:575` reads
one message with four ids. Lit, an `expect` message:
`contracts/metadata-service-roundtrip-conformance.test.ts:142` reads one
message. Dark: the comment at
`contracts/core-service-contracts.test.ts:3` ("[objectstack-ai#4127] The map claims a
binding per slot") reads 0. Planted in a scratch copy: an id put back
into a title reads 1 / 1, and an id in an added comment reads 0.
- **A wider pattern** (any `#` plus digits) reads 63 / 75 under
`contracts/` and 35 / 36 under `conversions/` test files at the base.
The extras are `(batch objectstack-ai#76)` in the
`resume-failure-report.pin.test.ts:137` title, and `PD objectstack-ai#12` (Prime
Directive 12, contract-first) in two `conversions.test.ts` titles,
`:180` and `:785`. None matches the gate's 3-to-5-digit pattern.
- **At the head:** 1413 messages / 1499 ids in 315 files. `contracts/`
reads 1 / 1 (the needle below), `conversions/` 0 / 0. Nothing else
moved. The wider pattern reads that needle and the two `PD objectstack-ai#12` titles,
and nothing else.

## How the area was chosen

Stage 10's rule: rank whole first-level directories by ids, and take the
busiest within about 10% of the ~100-id bound. `data/` (501), `ui/`
(415), `api/` (201) and `system/` (165) each exceed it alone, and the
files directly in `src/` (120) are 20% over. No single remaining
directory fits except smaller ones, and `contracts/` with `conversions/`
reads exactly 108, within 10% of the bound. That is the pairing the
stage-10 ACCEPT named, so the rule needed no second pass.

**Named for the next stages:** `data/` (about five stages, by
subdirectory or file group; `data/driver/` alone is 52), `ui/` (about
four), `api/` (two), `system/` (two), the files directly in `src/` (one,
120), and `security/`, `ai/`, `identity/`, `integration/`,
`migrations/`, `marketplace/`, `meta-spelling/` and `studio/` together
(one, 96).

## What each id became

32 ids in 21 literals now state a decision in words. 75 ids in 75
literals are dropped where the title already says what the record
decided. Every cited record was read with its comments through REST: 71
answer 200, and 7 answer 404 (objectstack-ai#6345, objectstack-ai#6523, objectstack-ai#11741, objectstack-ai#12010, objectstack-ai#12248,
objectstack-ai#16559, objectstack-ai#16786). For those seven the decision was read from what landed:
the landing commit and the CHANGELOG entry.

| record(s) | literal | now reads |
|:--|:--|:--|
| objectstack-ai#16293 | `action-confirmation-contract.pin.test.ts:63` |
"action-confirmation contract — an unconfirmed gated action is refused".
The ruling: a gated action without an explicit confirmation is refused
loudly, with the way to confirm. |
| objectstack-ai#10331 | `approval-service.test.ts:23` | "approval rows declare the
organization_id they are stamped with". The finding's first reading
landed: both row types declare it, optional and nullable. |
| objectstack-ai#19846 | `automation-context-caller-param-keys.pin.test.ts:49` |
"AutomationContext.callerParamKeys — the keys the caller supplied". The
ruling replaced the headless-screen inference with this explicit signal.
|
| objectstack-ai#18235 | `automation-service.test.ts:422` | "FlowRuntimeState —
carries the reason a flow is unbound", so a policy-disabled flow reads
differently from a broken binding. |
| objectstack-ai#15937 | `confirmed-blueprint-identity-contract.pin.test.ts:52` |
"confirmedBlueprintIdentity — declared on the protocol
ToolExecutionContext". The maintainer chose option 1: declare it in the
protocol, not only on cloud's augmentation. |
| objectstack-ai#11493 (2) | `data-driver.test.ts:290`, `data-engine.test.ts:455` |
"introspectSchema — an optional driver member at the spec shape" and
"introspectDatasource — answers the spec introspection shape". Both
ruled steps. |
| objectstack-ai#12248, objectstack-ai#11833 | `data-engine.test.ts:514` | "datasource resolution
members — declared, and optional". Fork 1 of the objectstack-ai#11833 ruling, option
A, landed as `8425c17cc`. |
| objectstack-ai#12248, objectstack-ai#12010, objectstack-ai#12805, objectstack-ai#11833 | `data-engine.test.ts:575` |
"datasource lifecycle members — declared at the shape the engine keeps".
Item 4 of the objectstack-ai#11833 ruling put `ConnectionEngineLike`'s members on the
contract (`8425c17cc`, `77b91bd`), and objectstack-ai#12805 caught the declared def up
to what the engine retains. |
| objectstack-ai#12482, objectstack-ai#12010, objectstack-ai#11833 | `data-engine.test.ts:679` | "syncObjectSchema
— declared, since two services already call it". |
| objectstack-ai#5040 | `http-server.test.ts:183` | "optional setFallbackHandler — a
not-found hook, not a wildcard route". The design's option C, so a
declared endpoint never shadows a registered route. |
| objectstack-ai#9835 | `http-server.test.ts:316` | "optional afterResponse — a
transport-agnostic response observer". |
| objectstack-ai#6617 | `job-service.test.ts:126` | "JobHandler degraded-outcome
channel — optional and additive". |
| objectstack-ai#14766, objectstack-ai#14501 | `job-service.test.ts:263` | "IJobService.replay force
option — a succeeded window replays only when forced". The A + a2
ruling. |
| objectstack-ai#4127 | `notification-service.test.ts:153` | "inbox — declared on the
contract, and optional". |
| objectstack-ai#5928 | `objectql-engine-hook-scope.test.ts:41` |
"IObjectQLEngine.registerHook scope faces — global minus excluded
objects". The ruled A shape, `excludeObjects`. |
| objectstack-ai#12248, objectstack-ai#11833 | `objectql-engine.test.ts:29` | "getObject return
contract — a structured answer, not `unknown`". Fork 3. |
| objectstack-ai#12481, objectstack-ai#12248, objectstack-ai#11833 | `objectql-engine.test.ts:106` | "getSchema
return contract — the same answer as its alias getObject". Fork 3, one
member over. |
| objectstack-ai#20157, objectstack-ai#19995 | `objectql-engine.test.ts:163` | "judgeFilter contract
— the engine judges a filter without running it". Ruling C. |
| objectstack-ai#4539 | `sharing-service.test.ts:35` | "recipient vocabularies, each
under its own name". The same-name, different-form exports were split by
renaming. |
| objectstack-ai#5858 | `sharing-service.test.ts:194` | "HierarchyScopeContext tenancy
authority — organizationId is authoritative". |

**Dropped only (75 ids):** objectstack-ai#3903, objectstack-ai#4045, objectstack-ai#4127 (2), objectstack-ai#4158, objectstack-ai#4251, objectstack-ai#4343,
objectstack-ai#4347 (2), objectstack-ai#4401, objectstack-ai#4456 (2), objectstack-ai#4538 (2), objectstack-ai#4827, objectstack-ai#4829, objectstack-ai#4923 (5), objectstack-ai#5011,
objectstack-ai#5122, objectstack-ai#5125, objectstack-ai#5126, objectstack-ai#5493 (3), objectstack-ai#5777, objectstack-ai#5817, objectstack-ai#5945 (4), objectstack-ai#6345 (2),
objectstack-ai#6428, objectstack-ai#6430, objectstack-ai#6523, objectstack-ai#6775 (2), objectstack-ai#6776, objectstack-ai#7378 (3), objectstack-ai#7616 (2), objectstack-ai#8321,
objectstack-ai#11122 (2), objectstack-ai#11741, objectstack-ai#11832, objectstack-ai#13700, objectstack-ai#13937, objectstack-ai#14103, objectstack-ai#14244, objectstack-ai#14384,
objectstack-ai#14945, objectstack-ai#14969, objectstack-ai#15389, objectstack-ai#15429, objectstack-ai#16231, objectstack-ai#16495, objectstack-ai#16559, objectstack-ai#16693, objectstack-ai#16786,
objectstack-ai#19620, objectstack-ai#20323, objectstack-ai#20390, objectstack-ai#20740, objectstack-ai#20935, objectstack-ai#20940, objectstack-ai#21005, objectstack-ai#21220, objectstack-ai#21458.

- Each title already states the pinned decision. In `conversions/` most
titles are the conversion entry's own id ("action-aria-removed",
"app-hidden-to-unpublished"), which is the decision that landed.
- **Qualified references** were read before dropping: "objectstack-ai#13937 shape 4"
(keep the consume order and add an operator exit verb; the title already
names the repairable run), "objectstack-ai#4923 house rule" (equal values dedupe,
different values keep both; the title already says "keeps BOTH"), "objectstack-ai#4127
batch 3" (the ledger extends past the enum; the title says "beyond the
enum"), "objectstack-ai#4251 B3" (`IObjectQLEngine` widens `IDataEngine`; the title
says it), "objectstack-ai#5122 shape" (a wrapper that forwards only required members;
the title says it), and "objectstack-ai#7378 row 1" / "row 3" (both refusal messages
already state their row's ruling).
- **The 404 records:** objectstack-ai#6345 (`e2798fa`: one driver vocabulary, `mongo`
converged to `mongodb`), objectstack-ai#6523 (`aa4b90d`: enforcement takes the full
`ExecutionContext`), objectstack-ai#11741 (`b706af9`: an optional `organizationId` on
both email inputs), objectstack-ai#16559 (`c7aca0dce`: the resume failure declared
once, carried by a success answer), objectstack-ai#16786 (`6059b29`: `updateById`
declares the record or `null`). Each title already carries what landed.
- **`(batch objectstack-ai#76)`** in `resume-failure-report.pin.test.ts:137` goes with
`objectstack-ai#16559` in the same literal. It names the decision batch whose ruling
the title already states ("the resume failure a success answer
carries"). It is outside the gate's pattern, and it is declared here.
- **`PD objectstack-ai#12`** stays in `conversions.test.ts:785`. It is a Prime
Directive reference in AGENTS.md, not a tracker number, and the
untouched sibling title at `:180` spells it the same way.

## The one id that stays

`contracts/approval-service.test.ts:274` is
`expect(doc).toContain('objectstack-ai#16495')`. It is not a title: it is the expected
value of an assertion that reads the `continueRestoredRun` docblock in
`contracts/approval-service.ts` and pins that the docblock names the
sibling it was ruled to copy. Changing it needs a code comment and
assertion logic, which this claim excludes. It moves with that docblock
when the comment lane rewrites `approval-service.ts:999`.

## Readers

- **Test-name filters:** none. A tracked-tree search for `-t` and
`--testNamePattern` finds only `packages/qa/dogfood/README.md:142` (`-t
"owner-scoped"`), which is unrelated.
- **Snapshots:** none. Neither directory has `__snapshots__`, and no
`.snap` file is tracked under `packages/spec`.
- **Projects:** none of the 34 files is listed in
`packages/spec/vitest.repo-tests.json`, so all run in the `local`
project.
- **By substring:** every old literal, plus a window around each id (241
needles), was searched across the tracked tree outside its own file. No
gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads
one. The 17 needle hits land on 11 lines:
- Sibling test strings in other lanes' packages:
`packages/objectql/src/metadata-service-roundtrip-conformance.test.ts:232`
(the objectql driver of the same table, `register must REFUSE this write
(objectstack-ai#7378)`);
`packages/plugins/plugin-security/src/get-queryable-fields.test.ts:136`
and `:165` (`[objectstack-ai#20935]`),
`resolve-permission-sets-for-context.pin.test.ts:103` (`[objectstack-ai#7616]`),
`authored-row-write-verdict.test.ts:350` (`[objectstack-ai#5493]`); and
`packages/metadata-core/src/artifact-forward-conversion.test.ts:277`
(`(ADR-0113, objectstack-ai#16693)`).
- Code comments:
`packages/spec/src/contracts/automation-service.ts:1061` (`objectstack-ai#13937 shape
4`), `packages/spec/src/contracts/index.ts:44` (`(objectstack-ai#4127)`),
`packages/cli/src/utils/view-container-names.ts:11` and
`packages/objectql/src/view-container-name-refusal.ts:13` (`objectstack-ai#7378 row
1`).

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. The declared lines are the three in
`metadata-service-roundtrip-conformance.test.ts`: the reference double's
two refusal messages (`:68`, `:75`) and the `expect` message at `:142`.
No test asserts on the dropped text: the refusal checks assert `code`,
`status` and the write's coordinates.

- **Result:** 33 of 34 files SAME on all three legs.
`conversions.test.ts` passes the skeleton and comment legs and is
flagged on one string, `:785`, because its rewritten title keeps `PD
objectstack-ai#12`. That was predicted in writing before the run. With `PD objectstack-ai#12`
spelled `PD-12` in both the base and head copies of that one line, the
file reads SAME with 19 changed titles.
- **Totals:** 96 changed literals, 93 titles and 3 declared. The diff's
`+` lines are exactly the 96 planned lines, and every file keeps its
line count.
- **Controls (10 of 10 as predicted, on scratch copies, each anchor hit
once):** identifier rename DIFF; numeric literal DIFF; comment edit
COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten
title given a new id VIOLATION; a title that was id-free at base edited
VIOLATION; one title reverted to base SAME; a declared string keeping an
id VIOLATION; an undeclared `expect` message changed VIOLATION; a title
re-split into a `+` chain DIFF.

**Test counts:** the 34 files were run at the base (in a separate base
worktree at `866b4393d0`) and at the head, in the `local` project. Both
sides read 595 / 595 passed, with the same count and status sequence per
file in 34 of 34. 354 full test names change, and each equals the base
name with the planned replacements applied. One full name repeats on
both sides: two `sqlite` rows of the `stored.test.ts` `it.each` table
share the `%s` name. That predates this PR.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
34 touched files are in it, and no `*.test.ts` at all. The controls
`src/shared/expression.zod.ts` and `dist/contracts/index.js` are in it.
- In `dist/`, four new phrases and three old ones each read in 0 files.
The control `Unrecognized key(s) on` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `72513933ee`)

- `pnpm turbo run build` over all packages: 71 / 71.
- `@objectstack/spec`:
  - `vitest run --project local`: 614 files, 18285 passed, 1 todo.
- `typecheck` exit 0, including `check:test-typecheck`. Its program
holds all 34 touched files, counted with `tsc --listFilesOnly`.
- **Gates:** `dispatch-gates --commands` derived 79 families (stage 12's
80 without `check:future-spec-major`, which no touched file feeds), and
all 79 exit 0. `--ran` reconciles: 79 derived, 79 run, 0 NOT-MEASURED, 0
UNRUN.
- The five roster families whose rosters sit under a touched directory
were also run, and each exits 0: `check:meta-url-spelling`,
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`
and `check:filter-alias-parity`.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 34
files, 0 errors and 0 warnings. The population comes from ESLint's own
config: 34 configured, 0 ignored. No `parserOptions.project` or
`projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 192 changed lines.

## Acceptance notes

- **The `objectstack-ai#16495` needle** at `approval-service.test.ts:274` stays, with
the `approval-service.ts:999` docblock it pins. The comment lane owns
both.
- **Code comments still carry ids** in these 34 files and in the two
directories' sources. Comments are not this card's share and are
untouched here.
- **Sibling test strings in other packages** repeat ids this PR dropped:
the objectql driver of the round-trip table, three `plugin-security`
test files and one `metadata-core` test (listed under Readers). Each is
its own lane's test-string stage.
- **`origin/main` moved** five commits past the base before this PR
opened (objectstack-ai#21752, objectstack-ai#21754, objectstack-ai#21753, objectstack-ai#21751, objectstack-ai#21743). None touches
`packages/spec`, so nothing was merged. objectstack-ai#21756, which also edits
`contracts/security-service.test.ts`, has no PR yet; whichever lands
later merges `origin/main`.

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

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

storage: a file field's accept / maxSize refusal (FileConstraintError) answers 500 INTERNAL_ERROR instead of a 4xx naming the field and constraint

2 participants