Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): audit the allowed publish/rollback and the 409 conflict denial (#7748) - #8401
Conversation
…9 conflict denial Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
… a non-short-circuiting harness Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
…ection; add changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
os-zhuang
commented
Aug 13, 2026
ACCEPT — PM review, |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7748
The defect is an inversion
protocol.tswrote an allowed-outcome audit row at exactly two sites — thesavepath and thedeletepath.publishMetaItemandrollbackMetaItemreachedrecordMetadataAuditthrough one route only,assertLockAllowsWrite, which records on the deny path and returns before any write on allow:So a refused publish was audited and a successful one was not — the inverse of what an audit trail is for. The 409
METADATA_CONFLICTrefusal is raised outside that helper (it comes back from the repository's parent-version check), so it wrote nothing either: a caller repeatedly losing an optimistic-concurrency race was indistinguishable, in the trail, from a caller who never tried.The QA run this came from: 3 publishes + 2 rollbacks, all 200, all with
X-Actor, producedCounter({'save': 5}).What changed
All three gaps the card names, in
packages/metadata-protocol/src/protocol.ts:publishMetaItemoperation: 'publish',outcome: 'allowed',code: 'ok'rollbackMetaItemoperation: 'rollback',outcome: 'allowed', note names the restored versionMETADATA_CONFLICT— all four sites (save / publish / rollback / delete)outcome: 'denied',code: 'metadata_conflict'The two allowed rows are placed exactly where the two pre-existing ones sit: after persistence has committed, before the projector. The four conflict sites route through one new private helper,
recordOptimisticConflictAudit, rather than four hand-written copies — those sites already carry four near-identical hand-written conflictErrors, and this is the lane that has paid twice this shift for one idea acquiring several spellings.sys_metadata_audithas always declaredpublishandrollbackasoperationoptions, so no schema work was needed — the table was designed for these rows and the writes were simply never made.Anti-vacuity
Every other multi-table fake engine in this repo opens
insertwithif (table === 'sys_metadata_audit') return { id: 'audit_skip' };. That is correct for a suite about something else and makes every assertion here vacuous — it reports "no audit rows" identically before and after the fix. The new harness persists audit rows like any other table, and asave-row control proves it can see them at all.It also separates attempted from landed.
recordMetadataAuditis best-effort by contract (ADR-0010 §3.6): it swallows its own insert failure. That makes a missing row ambiguous between "nothing ever tried to write it" (this defect) and "the write was attempted and failed" (a provisioning problem). The last case drives the second explicitly — audit table rejecting, publish still succeeds, attempt observed, row absent.Reverse verification
Direction predicted before running; the prediction was wrong and the correction is recorded in the test header rather than smoothed over. Predicted 5 red / 2 green; measured 6 red / 1 green. The best-effort-swallow case also goes red, correctly: under the defect no publish audit write is attempted, so there is nothing for the failing table to reject. Only the
savecontrol stayed green — the asymmetry that matters, proving the fix did not disturb the site that already worked.Taken by reverting
protocol.tstoorigin/mainfrom the commit, then restoring withgit checkoutfrom the branch;git statusclean afterwards, so the numbers were taken against the bytes that ship.Verification
metadata-protocol— 80 files / 1170 tests green (re-run after mergingmain)...@objectstack/metadata-protocol, i.e. consumers):objectql196/3496,rest110/1817, plus the two other suites whose fake engines drive publish/rollback without an audit branch (specapi/protocol.test.ts51,plugin-emailtemplate-runtime-write 14). These were checked deliberately: their stubs lack asys_metadata_auditbranch, so a new insert could have collided in their row maps.check:*gates parsed out of theESLintjob, pluscheck:i18n,check:type-check-coverage,check:agent-model-declared— 48/48 green.check:i18nfirst reported a prerequisite miss (workspace CLI not built) and was re-run green after building it.check:changeset-gate-self-tests,check:objectui-changeset,check:query-options-erasureandscripts/check-changeset-no-major.mjs, which the dispatch list did not name — all green.Scope
Batch
publishPackageDraftsis deliberately untouched and filed separately as #8400 — it promotes drafts inside oneengine.transaction(), where an audit row would roll back with the batch rather than record the refusal that caused it. That needs a Phase 2 placement and its own test, not a rider here. The actor stamped on the rows that are written stays out — that is #7749, which remains open.Generated by Claude Code