Skip to content

fix(service-storage): refuse a predicate update that writes a file field (#7102) - #7224

Merged
os-help merged 2 commits into
mainfrom
claude/issue-7102-bulk-file-field-refusal
Aug 10, 2026
Merged

fix(service-storage): refuse a predicate update that writes a file field (#7102)#7224
os-help merged 2 commits into
mainfrom
claude/issue-7102-bulk-file-field-refusal

Conversation

@os-help

@os-help os-help commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7102

The premise, reproduced before implementing

The issue is a lead, so the three-records-one-file end state was reproduced against origin/main first, with a per-row multi-UPDATE driver modelled on the engine's real dispatch (dispatchPerRowBeforeHooks + buildPerRowAfterContexts). Three matched records, one payload:

p1.image = 3862cb5e-ad11-43f4-a920-9dfe1d334015
p2.image = 3862cb5e-ad11-43f4-a920-9dfe1d334015
p3.image = 3862cb5e-ad11-43f4-a920-9dfe1d334015

One copy resolved once in beforeUpdate, written by one SET clause to all three rows, claimed by the first and refused to the other two by claimFile's never-steal branch. Read authorisation for those bytes then derives from a record the other two have nothing to do with — the widening exclusive ownership exists to prevent. Premise confirmed; the card's quoted line numbers had moved (measured at 2d1ddf0, now :433-436 and :584 after #7101), the facts had not.

The change

The lane ruled refuse, and the refusal lands in the beforeUpdate leg:

FILE_FIELD_BULK_WRITE_REFUSED / 400   (FileFieldBulkWriteError)

Both halves of the PM's mechanism assumption were verified against packages/objectql/src/engine.ts and hold:

  • (a) the predicate nature is visible therehookContext.dispatch.mode is bound to 'per-row' at the isPredicatePath branch, before the before phase; the module's existing perRowDispatch() already reads it.
  • (b) a throw surfaces with the envelope intactdispatchPerRowBeforeHooks is called outside update()'s try block and triggerHooks awaits handlers with no catch, so the error propagates uncaught to the caller. ADR-0058 Addendum II states the same intent in prose: per-row previous "is supplied so a guard can REFUSE the write (throw)".

The refusal fires on the first row, so the remaining N−1 dispatches and the updateMany never happen. Nothing is written, nothing is copied, and not even a sys_file lookup runs.

Scope — deliberately narrow

The condition is a file id token reaching a file-class field, decided by isFileIdToken — the same single arbiter copy-on-claim and the read resolver already ask, so the refusal covers exactly the writes this module would otherwise have to take ownership for, and nothing else. Three predicate writes own nothing and keep working per row, each pinned by a test:

predicate write verdict why
{ image: 'file_a' } refused one id, N records, one owner
{ image: null } allowed each row releases the file its own slot owned
{ image: 'https://…' } allowed not an id token; same release, no sharing
{ name: 'new' } allowed no file field touched, zero sys_file cost

Single-record updates, inserts and every delete path are byte-identical. Option 2 (copy per row) was ruled out by the card and not attempted — ADR-0058 Addendum II D3 names a row-conditioned rewrite of the shared SET clause as out of contract for before* hooks.

#7101's once-per-write beforeUpdate shape and its per-row afterUpdate guard are both left in place. The afterUpdate comment that filed this card is updated rather than deleted: what a predicate write can still deliver there is a bulk clear or a non-token value, and reconciling those per row is exactly right.

The falsifiable premise held

The ruling hung on "no legitimate in-repo consumer depends on a predicate update writing a file-class field succeeding". Verified and not falsified:

  • every multi: true call site outside packages/objectql was read; the only file-adjacent hits are sys_attachment join rows and e-mail attachments, neither of which is a file-class field write;
  • the file-class field names declared anywhere in the repo (image, gallery, avatar, cover, attachments, profile_pic, resume, doc, f_video, f_audio, …) appear in no bulk-update payload in packages/, examples/ (showcase, CRM, todo), packages/qa/ (dogfood, field-zoo, http-conformance) or the docs corpus;
  • @objectstack/service-storage is fully green (334/334) and so is @objectstack/spec (9382/9382).

Reverse verification

Direction predicted before running: red. The refusal block was removed (via a scripted string strip, never git stash) and the whole suite re-run:

PHASE A — refusal removed
  Test Files  1 failed | 22 passed (23)
       Tests  5 failed | 330 passed (335)

The five failures are exactly the five refusal pins; each fails as promise resolved "[ …(3) ]" instead of rejecting, i.e. the write went through and produced the shared id. The four "deliberately not refused" cases and the single-record contrast case stayed green in both directions, which is what shows the refusal is scoped and not a blanket ban. With the block restored, 334/334.

Rejection assertions

Every refusal case asserts the error's code and status, never a bare rejects.toThrow(). That is load-bearing here in the way #6050/PR #6142 describes: the pre-fix code does not throw at all on this path — it answers — so a throw-only assertion could not separate "refused with the wrong envelope" from "did not refuse".

Surface note — one file outside the declared surface, declared here rather than silently

The card scoped this to the file-reference-lifecycle pair. Minting a new error code cannot be done inside that surface: ADR-0112 D3 requires registration in ERROR_CODE_LEDGER, and the REST layer promotes a thrown error's .code onto the envelope — so an unregistered code would become a wire code by side effect, which is the "silent fourth state" the ADR exists to abolish. One row was therefore added to packages/spec/src/api/error-code-ledger.zod.ts, with the generated content/docs/references/api/*.mdx refreshed by pnpm --filter @objectstack/spec gen:docs. Those nine extra .mdx diffs are mechanical (… +257 more… +258 more, the ErrorCode union count) and check:docs reports 231 generated files in sync.

The alternative — reusing an already-registered code such as INVALID_REQUEST — is what the ledger's own header warns registration friction pushes authors toward, and it would leave clients unable to branch on a brand-new refusal. Flagging for the PM: this is a registry entry the ruled fix requires, not a consumer patched to dodge the refusal.

Verification

pnpm --filter '@objectstack/service-storage^...' build            → BUILD_EXIT=0
pnpm --filter @objectstack/service-storage test                   → 23 files, 334/334 passed
pnpm --filter @objectstack/spec test -- error-code-ledger         → 359 files, 9382/9382 passed
node scripts/check-error-code-casing.mjs --self-test              → 17 cases pass
node scripts/check-error-code-casing.mjs                          → no lowercase error codes in 3372 files
node scripts/check-nul-bytes.mjs                                  → OK (6619 files, no raw control bytes)
pnpm --filter @objectstack/spec check:authorable-surface          → 1587 schemas, exit 0
pnpm --filter @objectstack/spec check:docs                        → 231 generated files in sync
npx eslint (the three changed source files)                       → exit 0

All of the above re-run after merging origin/main (3566e5520), with a clean tree afterwards. tsc --noEmit on the package reports 51 errors in its test layer; all four in this file are pre-existing driveInsert return-type errors in tests this PR does not touch, and this package has no check:test-typecheck script (only spec and client do), so the count is unchanged by this change.

Changeset: patch for @objectstack/service-storage and @objectstack/spec, naming the before/after.


Generated by Claude Code

claude added 2 commits August 10, 2026 02:59
…eld (#7102)

A predicate (`multi: true`) update has ONE payload for N matched rows, so a
file id written through one landed in every matched record while at most one
of them could own it — read authorisation for those bytes then derived from a
record the others have nothing to do with, which is the exact widening the
exclusive-ownership design exists to prevent. Two log warnings were the only
signal and nothing failed.

That write is now refused in `beforeUpdate`, before the driver runs, with an
ADR-0112 envelope error (`FILE_FIELD_BULK_WRITE_REFUSED` / 400). The refusal
is scoped to a file id TOKEN reaching a file-class field — decided by
`isFileIdToken`, the same arbiter copy-on-claim already uses — so a bulk clear,
an external URL and a legacy inline blob still work per row, and every
single-record path is byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 10, 2026 3:06am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-storage, @objectstack/spec.

107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/api/plugin-endpoints.mdx (via @objectstack/service-storage)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-storage, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/service-storage, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/service-storage, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A predicate update writing a file field gives N records one file id under an exclusive-ownership model

2 participants