Skip to content

fix(metadata-protocol,spec): preserveAudit is UPDATE-only — narrow the contract and warn loudly on a non-system INSERT (#6640) - #6823

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-6640-preserve-audit-insert-loud
Aug 8, 2026
Merged

fix(metadata-protocol,spec): preserveAudit is UPDATE-only — narrow the contract and warn loudly on a non-system INSERT (#6640)#6823
os-zhuang merged 3 commits into
mainfrom
claude/issue-6640-preserve-audit-insert-loud

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #6640

What was wrong

FieldSchema.readonly's .describe() promised the opt-in historical-import exemption (preserveAudit, #3493) on both write paths, and content/docs/protocol/objectql/security.mdx:264 agreed. Only UPDATE ever implemented it:

  • UPDATEstripReadonlyFields in objectql's rule-validator consults isPreservableUnderAudit (packages/objectql/src/validation/rule-validator.ts:1149). Reads preserveAudit.
  • INSERTstripReadonlyForInsert at the DataProtocol ingress (packages/metadata-protocol/src/protocol.ts) never had preserveAudit in its signature at all; context.isSystem was its only exemption. Verified on origin/main: git grep preserveAudit packages/metadata-protocol/src/protocol.ts returned zero hits.

REST import's treatAsHistorical puts preserveAudit: true on the write context (packages/rest/src/import-runner.ts:407) and creates through p.createData (:641 / :762) — i.e. through exactly that ingress. So one historical import kept an author-declared readonly business column (closed_at, resolved_by) on the rows it UPDATED and silently dropped it from the rows it CREATED.

The trigger is not exotic. The audit family itself is readonly: true in the registry's AUDIT_FIELD_DEFS (packages/objectql/src/registry.ts:262), so an ordinary export → historical-import round-trip carries readonly columns on every row.

All three halves of the issue's premise verified on origin/main before implementing; all three held.

What this does — maintainer ruling of 2026-08-08 (option 2 + binding loudness rider)

1. Contract narrowed to the truth. The .describe() text and the security doc now state the exemption as UPDATE-path only. The INSERT entry keeps honouring isSystem alone; replaying archival readonly facts on create requires the system context. Honouring preserveAudit here instead would have handed a NON-system caller — treatAsHistorical arrives on an ordinary REST import request — the approval/status columns #3043 exists to protect, in a single POST.

2. The ignored request stops being silent. A non-system INSERT that requests preserveAudit and actually loses fields now logs a WARN naming the object, every stripped field, the UPDATE-only rule, and the isSystem remedy. It fires once per ingress call (the union across a batch, the same aggregation mergeDroppedFieldEvents already applies), and only when something was really removed — an ordinary create that never asked for the exemption stays exactly as quiet as #3043 designed it. That specificity is the point: the drop itself already surfaces through droppedFields (#3431), but a caller who explicitly requested the exemption could not tell "your fields were stripped by the ordinary rule" from "the exemption you requested does not exist on this path". The warning says the second one, by name.

Because the signal lives inside stripReadonlyForInsert, all five ingress sites (createData, cloneData, batchData create, createManyData, insertManyData) get it by construction rather than by five copies.

No behaviour change to the strip itself, and no acceptance-surface changecheck:authorable-surface is byte-identical (working tree clean after the run). The spec diff is the describe string only, plus the whole-file regeneration of content/docs/references/data/field.mdx that check:docs demands.

Why a WARNING and not a throw — measured, not assumed

The ruling made loudness binding and left the shape to whichever can be both loud and non-breaking. A throw cannot be. runImport's per-row writer collects a write error into toFailedResult(rowNo, res.error) rather than aborting, so refusing at the ingress would not stop a historical import — it would convert every row it CREATES into a failed row while the rows it updates still succeed.

Measured on this branch with a throwing variant of the same condition, driving the real runImport → real ingress with 2 new rows:

PROBE_RESULT created=0 errors=2 ok=0
PROBE_ROW0 {"row":1,"ok":false,"action":"failed",
            "error":"PROBE: preserveAudit is UPDATE-only",
            "code":"PRESERVE_AUDIT_INSERT_UNSUPPORTED"}

{created: 2, errors: 0}{created: 0, errors: 2}. Breaking the shipped treatAsHistorical flow for new rows is precisely the condition under which the ruling names the loud WARNING — strip still applied — as the containment-correct landing. So: loud warning, and this is the PR body saying so, as the ruling requires.

Family precedent #5714/#5931 rejects outright, and the difference is measured rather than assumed: those two judge AUTHORING input, before anything runs. This one sits on a live write path, which is what moves it from throw to warn.

Tests — through the REAL entry, per the binding test note

The pre-existing preserveAudit pins all drive engine.insert directly and therefore cannot see this ingress; that is how the gap survived. Every new pin goes through the shipped entry.

  • packages/metadata-protocol/src/protocol.readonly-insert.test.ts (+6): non-system INSERT with preserveAudit → stripped AND one warning carrying the field names, the UPDATE-only rule and the remedy; system INSERT unchanged; ordinary non-preserveAudit create still silent; preserveAudit that loses nothing still silent; createManyData warns once with the union across rows; batchData create carries the same signal.
  • packages/rest/src/import-runner-historical-readonly-insert.test.ts (new): the historical import end to end — real ObjectStackProtocolImplementation over a mock engine, so the runner, the ingress and the signal are all shipped code and only storage is faked. Pins that the import still creates every row, that the strip is unchanged, that the signal fires, and that the system-context run replays the archival value.

Reverse verification — direction predicted first

Predicted: removing the warn call (restoring the pre-fix silence) turns the loudness pins red and leaves every strip pin green, because this PR adds a signal and does not change the strip. Measured, exactly that:

metadata-protocol:  Tests  3 failed | 9 passed (12)
  × strips the readonly fields (enforcement unchanged) and names them in one loud warning
  × createManyData warns ONCE with the UNION of what every row lost
  × batchData create carries the same signal (every ingress, by construction)

rest:               Tests  1 failed | 2 passed (3)
  × still CREATES every row — the loud signal replaces the silence, not the flow
  AssertionError: the historical create path emits the signal: expected 0 to be greater than 0

Note the honest shape: the three "stays silent" / system-context pins stay green under the revert, and they should — they assert absence, which the pre-fix code also satisfies. They earn their place against the opposite regression (a warning that fires too widely), not against this one.

Gates run locally, foreground

pnpm lint                                            (clean)
check:engine-double-contract / error-code-casing / route-envelope
  / durability-log-level / doc-authoring / role-word / adr-anchors   OK
check:authorable-surface / check:docs / check:generated --reconcile-only
  / check:spec-changes                                               OK
check:nul-bytes    OK (6359 files) + targeted control-byte self-scan clean
typecheck  (spec, metadata-protocol, rest)                           Done
metadata-protocol test   Test Files 63 passed   Tests  746 passed
rest test                Test Files 70 passed   Tests 1100 passed
objectql (UPDATE-path preserveAudit regression, untouched package)
                         Test Files  5 passed   Tests  271 passed

File surface

Stayed inside the declared surface. Round-4 siblings on protocol.ts#6780 (:7829) and #6710 (:2426) — are untouched; this change sits at ~:1108.


Generated by Claude Code

@vercel

vercel Bot commented Aug 8, 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 8, 2026 11:13pm

Request Review

@github-actions github-actions Bot added the size/m label Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

112 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/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 @objectstack/metadata-protocol, 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/metadata-protocol, @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/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/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/metadata-protocol, @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/releases/implementation-status.mdx (via @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/metadata-protocol, @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)

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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Aug 8, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 8, 2026 23:39
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 8, 2026
Merged via the queue into main with commit 2ab1257 Aug 8, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-6640-preserve-audit-insert-loud branch August 8, 2026 23:55
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 protocol:data size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[观察] stripReadonlyForInsert 完全不读 preserveAudit —— readonly 的历史导入豁免在 INSERT 侧是 declared ≠ enforced

2 participants