You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
draft-publish-lifecycle: the metadata audit trail records only save — publish, rollback and the 409 conflict denial never write a row #7748
Staging, publishing and rolling back a metadata item all answer 200, and the audit trail records only one of the three.
After 3 publishes and 2 rollbacks (all 200, all sent with X-Actor), GET /api/v1/meta/<type>/<name>/audit returns Counter({'save': 5}) — no publish rows, no rollback rows.
The 409 METADATA_CONFLICT denial is likewise absent.
Reproduced twice, on two actors. Everything else in the lifecycle passes: draft staging, the publish flip, OCC (409 with a passing control), package-wide publish-drafts atomicity, the #4463 draft→active gate, the Studio unpublished badge and Changes counter, /history carrying both publish events, and rollback flipping the served body. So the operations happen — they are simply not written down.
Root cause (source-confirmed)
packages/metadata-protocol/src/protocol.ts writes an allowed-outcome audit row at exactly two sites:
line 10220 — operation: 'save', outcome: 'allowed' (in the save path, commented "ADR-0010 — success audit (best-effort)")
line 12848 — operation: 'delete', outcome: 'allowed'
publishMetaItem and rollbackMetaItem reach recordMetadataAuditonly through assertLockAllowsWrite() (called at lines 10783 and 12414). That helper records on the deny path and returns early on allow:
const refusal = evaluateLockForWrite(state.lock);
if (!refusal) return null; // ← allowed: returns before any audit write
…
await this.recordMetadataAudit({ …, outcome: 'denied', … });
So a refused publish is audited and a successful one is not — the inverse of what an audit trail is for. The same asymmetry explains the missing 409: the conflict is refused outside the lock helper, and nothing on that route writes a row either.
Stale-premise check: re-verified on objectstackorigin/main (00e9196). Both allowed-outcome sites and both assertLockAllowsWrite call sites are unchanged in substance; only line numbers moved from the run's build (save 9833 → 10220, delete 12429 → 12848).
Reproduction
Boot the showcase with writable runtime packages.
Stage a draft (PUT …?mode=draft), publish it, roll it back — repeat to 3 publishes and 2 rollbacks, sending X-Actor on every call. All answer 200.
GET /api/v1/meta/<type>/<name>/audit → only save rows (Counter({'save': 5})).
Force a 409 METADATA_CONFLICT (stale If-Match), then re-read the audit — the denial is absent too.
Scope
The actor stamped on the rows that are written is a second, independent defect with a different located file — filed separately as #7749 so it survives this fix.
Source
Extracted from the QA run #7695 (framework 92f26f7, console 09987b680).
Deliberately deferred this round — behind #8136, not dropped. Recording the known traps now, at the moment of deferring, so whoever picks this up does not re-derive them.
Why deferred. This card edits the audit-write sites in packages/metadata-protocol/src/protocol.ts. #8136 was claimed this round and is a file-wide producer sweep of the same file (raw driver text out of client-facing messages). Its region and this card's region are adjacent in the error/refusal paths — assertLockAllowsWrite records on the deny path, which is exactly the kind of site #8136 is rewriting. Two agents restructuring the same catch/refusal paths concurrently is how a merge lands green and semantically wrong. This card is dispatched as soon as #8136 lands.
Stale-premise re-check, run against origin/main just now (this card's own check was against 00e9196 on 2026-08-11):
recordMetadataAudit — still 5 occurrences in protocol.ts. Substance intact.
The two allowed-outcome sites have moved again: the card says save 10220 / delete 12848; they are now at 10627 and 13618. That is the second drift on this card. ⇒ Re-locate by reading, never by line number.
⚠️New signal worth checking before implementing:protocol.ts:9161 now carries a comment reading "…writes its ordinary outcome: 'allowed' row, so nothing…". That did not exist in the excerpt this card was filed from. Something in the audit path has been touched since. Read that comment and its surrounding branch first — it may mean part of this defect is already closed, or that a sibling changed the invariant this card assumes. A premise_still_valid: false outcome here would be a legitimate delivery, not a failure.
Scope reminder carried forward from the card: the actor stamped on the rows that are written is a separate defect with a different located file, filed as #7749. It stays out of this card.
⚠️A trap for whoever picks this up, relayed from the #8031 dev's handover (recovered over git; its container is 403-blocked on the API). Recording it here because it is precisely the reasoning that would close this card wrongly.
⛔ Do not conclude "publish is already recorded, so #7748 is stale"
documented as "'publish' is recorded when a draft is promoted, 'revert' when a historical version is restored".
That is the sys_metadata_history table — a different table from the audit trail this card describes. Someone reading that declaration will find the word publish next to the word "recorded" and conclude this card is already satisfied. It is not: they would be reading the history table's contract and drawing a conclusion about the audit table.
And a precise statement of what #8031's green run does NOT prove
The #8031 dev was told this card is held behind work in the same neighbourhood, and it declined to claim any coverage — correctly, and worth quoting:
My stub engine short-circuitssys_metadata_audit inserts (return { id: 'audit_skip' }). So my suite is silent on audit behaviour — it neither confirms nor refutes #7748. Do not read my green run as evidence either way.
⭐ That is the right shape of report: a green suite that touched the neighbourhood, with an explicit statement of what it did not measure. ⛔ Do not treat PR #8254's passing tests as coverage of this card's defect.
It changed nothing on this path, per its scope fence.
Status of the hold
This card was held behind #8136 (the protocol.ts error-path producer sweep), not behind #8031. That hold and its earlier stale-premise re-check still stand — see the note of 2026-08-12 18:0xZ, including the ⚠️protocol.ts:9161 comment that postdates this card and may mean part of the defect is already closed. Read that first.
Held again this round — behind #8136, same ground as before. Not dropped, and ⛔ nothing about the card's merits has changed.
#8136 was reclaimed from a dead claim at 02:1xZ and its reclaim note said "#7748 is unblocked as of this comment". That was true only in the sense that nothing was in flight at that moment. #8136 is dispatched again this round (comment 5275484126), so the original reason for the hold is live again, and it is the right reason: #8136 is a file-wide producer sweep of protocol.ts's error/catch paths, and this card edits the audit-write sites — including assertLockAllowsWrite, which records on the deny path, exactly the kind of site #8136 is rewriting. Two agents restructuring the same catch/refusal paths concurrently is how a merge lands green and semantically wrong.
Note this is a narrower hold than "the file is busy": #7654 was dispatched into protocol.ts this same round, as the second concurrent editor under the region exemption, because its region (the getMetaItems read merge) genuinely does not touch the refusal paths. Yours does. That is the whole distinction.
Release condition: dispatched as soon as #8136 lands. State is pm:queue, unassigned — ⛔ deliberately not pm:blocked, because this is a serialisation hold, not an upstream dependency; the card needs the file free, not #8136's outcome.
Traps already recorded on this card — read them before implementing, they are still current:
The two allowed-outcome audit sites have drifted twice now. ⇒ re-locate by reading; ⛔ never by line number.
⚠️protocol.ts:9161's comment about an ordinary outcome: 'allowed' row postdates this card — read that branch first; part of the defect may already be closed, and premise_still_valid: false would be a legitimate delivery.
⛔ Do not conclude "publish is already recorded, so this is stale" from ExtendedOperation's 'publish' — that is the sys_metadata_history table, a different table from the audit trail this card describes.
HOLD RELEASED.#8136 landed as 5905d7f51 (PR #8334), so the card this was serialised behind is done and protocol.ts carries no in-flight claim from this lane. State stays pm:queue, unassigned — dispatchable at the next batch.
⚠️protocol.ts moved substantially while you waited. Two PRs landed in it this round — 5905d7f51 (#8136, error/catch message construction) and d5031f6a1 (#7654, the getMetaItemsread merge) — on top of the four merges that preceded them. Every line number on this card and in its comments is stale by construction; re-locate by reading, and anchor on function names.
⚠️One of those landings touches your neighbourhood directly.#8136 rewrote the error/catch paths of this same file, and your card edits the audit-write sites — including assertLockAllowsWrite, which records on the deny path. That adjacency is exactly why you were held rather than run concurrently. Now that it has landed you are reading a settled file rather than racing one, but re-read those refusal paths before assuming this card's description of them still holds: a premise_still_valid: false outcome is a legitimate delivery.
The traps recorded earlier all still stand — read them before implementing:
The two allowed-outcome audit sites have drifted twice; ⛔ never locate by line number.
⚠️protocol.ts:9161's comment about an ordinary outcome: 'allowed' row postdates this card. Read that branch first — part of the defect may already be closed.
⛔ Do not conclude "publish is already recorded, so this is stale" from ExtendedOperation's 'publish' — that is the sys_metadata_history table, not the audit trail this card describes.
⚠️Batch note for the PM dispatching this:protocol.ts now has four eligible candidates — this card, #8184, #8268 and #7860. The region-exemption cap is 3 concurrent editors with region-level declarations, and cards wanting the same lines serialise regardless. ⛔ Do not dispatch all four together.
Claim: PM loop round 2
Session: session_012WMpuAfA2KSdDjGF6tm1bH
Branch: claude/issue-7748-lifecycle-audit-rows
Worktree: objectstack-issue-7748
Domain: domain:metadata
File surface: packages/metadata-protocol/src/protocol.ts — region: the audit-write sites only (the two allowed-outcome sites, assertLockAllowsWrite, and the 409 conflict route). ⛔ NOT assertFilterFieldsExist (#8296 is in it), ⛔ NOT the read-exit materialization stamps (#8268 is in them), ⛔ NOT saveMetaItem's package door (landed as 003feae65 — read it, do not move it). (stop on breach; explain in the report)
Container & model: M, mode:subagent, model: opus
Serial constraints cleared: third of three concurrent editors on protocol.ts, at the region-exemption cap. Siblings: #8296 (assertFilterFieldsExist) and #8268 (read-exit stamps). ⛔ #7860 is NOT dispatched — that would be a fourth. Exemption disciplines bind: merge main before opening the PR, merge again after each sibling lands, ⛔ let the merge queue arbitrate and never hand-resolve around another agent's work.
Held since ~17:5xZ yesterday behind #8136, released when that landed (5905d7f51); the traps recorded on the hold and hold-release comments (5270586423, 5275146718, 5277073360) are the brief and all still bind. ⚠️protocol.ts has taken four landings while this waited — d5031f6a1, 5905d7f51, 003feae65 and the wave-1 merges. Every line number on this card is stale by construction.
⚠️ One of those landings is in your neighbourhood: 5905d7f51 (#8136) rewrote the error/catch message construction across this file, and your region includes assertLockAllowsWrite's deny path — which records the audit row it refuses on. Read that branch as it stands now before assuming this card's description of it holds. premise_still_valid: false is a legitimate delivery.
{
"issue": 7748,
"status": "done",
"branch": "claude/issue-7748-lifecycle-audit-rows",
"pr": "https://github.com/objectstack-ai/objectstack/pull/8401",
"premise_still_valid": true,
"summary": "Premise confirmed against origin/main by reading, not line numbers: publishMetaItem and rollbackMetaItem still reached recordMetadataAudit only via assertLockAllowsWrite (deny-path only, early return on allow), and all four 409 METADATA_CONFLICT sites wrote nothing. Added the allowed-outcome publish and rollback rows in the same position the two pre-existing sites (save, delete) use, and routed all four conflict sites through one new private helper recordOptimisticConflictAudit writing outcome:'denied', code:'metadata_conflict'. THE 9161 COMMENT IS A FALSE ALARM AND CLOSES NOTHING: relocated by content (now ~9385), it belongs to getEffectiveLock's #5706 fail-open catch and its phrase 'writes its ordinary outcome:allowed row' refers to the SAVE path's existing row — it is a remark about what the fail-open window costs the trail, not a publish/rollback audit write. Also measured: sys_metadata_audit already declares 'publish' and 'rollback' as operation picklist options, so the table was designed for these rows from the start and only the writes were missing. Issue body was HTML-escaped, not sanitizer-truncated — all five sections intact.",
"tests": "New: packages/metadata-protocol/src/protocol.lifecycle-audit-rows.test.ts, 7 cases, all green. Its fake engine deliberately does NOT short-circuit the audit insert (the PR #8254 vacuity trap) and records attempted-vs-landed separately. REVERSE VERIFICATION, direction predicted before running and PREDICTION WAS WRONG — predicted 5 red / 2 green, MEASURED 6 red / 1 green on 'git checkout origin/main -- protocol.ts': 'expected [] to have a length of 1 but got +0', 'expected { save: 2 } to match object { save: 2, publish: 2, rollback: 1 }', 'expected [ save ] to include publish', 'expected false to be true'. The extra red is the best-effort-swallow case: under the defect no publish audit write is ATTEMPTED, so the failing table has nothing to reject — correct direction, header corrected to the measurement rather than the guess. Only the save control stayed green (the asymmetry that matters). Restored via git checkout from the branch commit; git status clean, so numbers were taken against shipping bytes. Suites: metadata-protocol 80 files/1170 tests green (re-run after merging main); downstream consumer sweep using the PREFIX filter '...@objectstack/metadata-protocol' (= consumers): objectql 196 files/3496 tests, rest 110 files/1817 tests, spec api/protocol.test.ts 51, plugin-email template-runtime-write 14 — those last two chosen deliberately because their fake engines drive publish/rollback with NO sys_metadata_audit branch, so a new insert could have collided in their row maps. Gates: all 45 check:* parsed out of the ESLint job plus check:i18n, check:type-check-coverage, check:agent-model-declared = 48/48 green. check:i18n first failed on an unmet prerequisite (workspace CLI not built in a fresh worktree; it states plainly that nothing was checked) and went green after 'turbo run build --filter=@objectstack/cli'. Re-derivation against actual changed paths surfaced four the dispatch list did not name — check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, scripts/check-changeset-no-major.mjs — all green. check:objectui-pin-fresh skipped per dispatch instruction (known stale local checkout; CI is authority).",
"open_questions": [],
"out_of_scope_findings": [
"filed as #8400: publishPackageDrafts writes no sys_metadata_audit rows at all — Studio's 'publish whole app' leaves the trail empty. Left out of this PR deliberately, not overlooked: it calls promoteDraftForPublish inside ONE engine.transaction(), so an audit row written there rolls back with the batch — including the refusal that caused the rollback, which is exactly the defect #7748 exists to close. Both pre-existing allowed-outcome sites write after their repository transaction has closed; matching them for the batch needs a Phase 2 placement off promoted[] plus its own test. Searched open issues first (keyword + file path): no duplicate. Filed unassigned and unlabeled for PM triage."
]
}
Notes the JSON cannot carry
Region fence held — no breach. Edits are confined to the audit-write sites: the publishMetaItem / rollbackMetaItem allowed rows, the four 409 conflict catches, and one new private helper beside assertLockAllowsDelete. ⛔ assertFilterFieldsExist (#8296), the read-exit materialization stamps (#8268) and saveMetaItem's package door (003feae65) were read but not touched — git diff origin/main -- protocol.ts is +130 lines, pure additions, zero deletions.
Concurrent-editor discipline. Merged origin/main before opening the PR, as the third of three editors under the region exemption; the merge was clean with no conflicts in protocol.ts, and the package suite was re-run green afterwards. Nothing was hand-resolved around a sibling's work. Happy to re-merge after #8296 or #8268 lands — the merge queue should arbitrate.
On the two traps the card warned about, both confirmed rather than assumed: I did not read ExtendedOperation's 'publish' as coverage (that is sys_metadata_history, a different table — the audit operation column is its own vocabulary, and it separately already declares publish/rollback options); and PR #8254's stub return { id: 'audit_skip' } is exactly what my harness refuses to do.
One thing worth the PM's eye: the "best-effort" question the dispatch raised is real and is now pinned. A failed audit write is swallowed with a console.warn, so "the row is missing" and "the row failed to write" are indistinguishable to a caller. The new suite separates them by recording every insert aimed at the table alongside the ones that landed — without that, a future regression that stopped attempting the write would look identical to an unprovisioned table and stay green.
Confirmed by both readings: the merge event and the commit on origin/main (11:28:46Z), never the auto_merge field. PR #8401, closed automatically by its Fixes trailer; pm:dispatched dropped.
What shipped. The defect was an inversion: publishMetaItem and rollbackMetaItem reached recordMetadataAudit through 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 409 METADATA_CONFLICT refusal is raised outside that helper and wrote nothing at all, leaving a caller repeatedly losing an optimistic-concurrency race indistinguishable, in the trail, from one who never tried.
Now: a successful publish writes operation: 'publish' / outcome: 'allowed', a successful rollback the same with the restored version named, both in the same position as the pre-existing save and delete rows; and all four routes that can raise the 409 go through one helper, recordOptimisticConflictAudit, rather than four hand-written copies beside the four near-identical hand-written conflict Errors already there. sys_metadata_audit had always declared publish and rollback as operation options — the table was designed for these rows and the writes were simply never made.
Three things worth carrying forward.
The reverse verification was wrong and said so. It predicted 5 red / 2 green and measured 6 red / 1 green, then recorded the mismatch and its cause in the test header rather than retro-fitting the prediction: the best-effort-swallow case is load-bearing on auditAttempts.some(a => a.operation === 'publish'), and under the defect no publish audit write is ever attempted, so the failing table has nothing to reject. Only the save control stayed green — the asymmetry proving the fix did not disturb the one site that already worked.
It found a third state nobody asked for.recordMetadataAudit is best-effort by contract (ADR-0010 §3.6), so a missing row is ambiguous between nothing ever tried (this defect) and attempted and rejected (a provisioning problem). A test that only counts rows passes forever on the second. Recording attempts separately from landings keeps them apart, and the last case drives the rejected-table path explicitly.
The vacuity trap was named, not stumbled into. Every other multi-table stub in this repo opens insert with if (table === 'sys_metadata_audit') return { id: 'audit_skip' } — correct elsewhere, fatal here, because it reports "no audit rows" identically before and after the fix. This harness persists them, and the save control exists to prove the number 0 means something.
Residues:
publishPackageDrafts writes no audit rows — Studio's "publish whole app" leaves the audit trail empty #8400 (bug / pm:queue, this lane) — batch publishPackageDrafts writes no audit rows at all, so Studio's "publish whole app" leaves the trail empty. Deliberately excluded rather than overlooked: it promotes inside one engine.transaction(), where an audit row rolls back with the batch — including the refusal that caused the rollback, which is the very defect this card closed. Needs a Phase 2 placement off promoted[] and its own test.
A premise question also resolved on the way: the protocol.ts comment that appeared to claim publish/rollback rows already existed is a false alarm. Relocated by content, it belongs to getEffectiveLock's #5706 fail-open catch, and its "writes its ordinary outcome:allowed row" refers to the save path. It closes nothing.
Attribution note — the audit rows this card added inherit the #7941 rule
Recording this here because the #7941 ruling asks for it, and because this card is already closed (by PR #8401): no action is needed on it, and it is not being re-opened. This is a pointer for anyone reading the publish/rollback audit trail later.
PR #9119 implements the maintainer's ruling on #7941: the authenticated identity is the recorded actor on all five /meta write sites, and X-Actor no longer outranks it. The header limb was removed from resolveMetaWriteActor rather than reordered, so a caller cannot choose the name an audit row records in any shape — including on the machine-write path, where there is no identity for the header to lose to.
The publish, rollback and 409-denial rows this card added flow through that same single producer, so they inherit the rule automatically — there is nothing separate to change here:
sys_metadata_audit.actor and sys_metadata_history.recorded_by on a publish or rollback now name the principal the request was actually authorized as.
The 'system' / NULL defaults still apply to genuine machine writes with no principal, unchanged.
Relevant if this card's behaviour is ever re-verified from the QA checklist: docs/qa/platform-checklist/areas/studio-authoring.json still describes the actor as resolved "from X-Actor / the session identity", which is now stale. Filed separately and unassigned as #9118.
Related: #7941 (the ruling), #7749 (the producer fix that made the precedence load-bearing), PR #9119.
Activity
huangyiirene commentedon Aug 12, 2026
Deliberately deferred this round — behind #8136, not dropped. Recording the known traps now, at the moment of deferring, so whoever picks this up does not re-derive them.
Why deferred. This card edits the audit-write sites in
packages/metadata-protocol/src/protocol.ts. #8136 was claimed this round and is a file-wide producer sweep of the same file (raw driver text out of client-facing messages). Its region and this card's region are adjacent in the error/refusal paths —assertLockAllowsWriterecords on the deny path, which is exactly the kind of site #8136 is rewriting. Two agents restructuring the same catch/refusal paths concurrently is how a merge lands green and semantically wrong. This card is dispatched as soon as #8136 lands.Stale-premise re-check, run against
origin/mainjust now (this card's own check was against00e9196on 2026-08-11):recordMetadataAudit— still 5 occurrences inprotocol.ts. Substance intact.10220/ delete12848; they are now at10627and13618. That is the second drift on this card. ⇒ Re-locate by reading, never by line number.protocol.ts:9161now carries a comment reading "…writes its ordinaryoutcome: 'allowed'row, so nothing…". That did not exist in the excerpt this card was filed from. Something in the audit path has been touched since. Read that comment and its surrounding branch first — it may mean part of this defect is already closed, or that a sibling changed the invariant this card assumes. Apremise_still_valid: falseoutcome here would be a legitimate delivery, not a failure.Scope reminder carried forward from the card: the actor stamped on the rows that are written is a separate defect with a different located file, filed as #7749. It stays out of this card.
Generated by Claude Code
huangyiirene commentedon Aug 13, 2026
⛔ Do not conclude "publish is already recorded, so #7748 is stale"
packages/metadata-protocol/src/sys-metadata-repository.tsdeclares:documented as "
'publish'is recorded when a draft is promoted,'revert'when a historical version is restored".That is the
sys_metadata_historytable — a different table from the audit trail this card describes. Someone reading that declaration will find the wordpublishnext to the word "recorded" and conclude this card is already satisfied. It is not: they would be reading the history table's contract and drawing a conclusion about the audit table.And a precise statement of what #8031's green run does NOT prove
The #8031 dev was told this card is held behind work in the same neighbourhood, and it declined to claim any coverage — correctly, and worth quoting:
⭐ That is the right shape of report: a green suite that touched the neighbourhood, with an explicit statement of what it did not measure. ⛔ Do not treat PR #8254's passing tests as coverage of this card's defect.
It changed nothing on this path, per its scope fence.
Status of the hold
This card was held behind #8136 (the⚠️
protocol.tserror-path producer sweep), not behind #8031. That hold and its earlier stale-premise re-check still stand — see the note of 2026-08-12 18:0xZ, including theprotocol.ts:9161comment that postdates this card and may mean part of the defect is already closed. Read that first.Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
Held again this round — behind #8136, same ground as before. Not dropped, and ⛔ nothing about the card's merits has changed.
#8136 was reclaimed from a dead claim at 02:1xZ and its reclaim note said "#7748 is unblocked as of this comment". That was true only in the sense that nothing was in flight at that moment. #8136 is dispatched again this round (comment
5275484126), so the original reason for the hold is live again, and it is the right reason: #8136 is a file-wide producer sweep ofprotocol.ts's error/catch paths, and this card edits the audit-write sites — includingassertLockAllowsWrite, which records on the deny path, exactly the kind of site #8136 is rewriting. Two agents restructuring the same catch/refusal paths concurrently is how a merge lands green and semantically wrong.Note this is a narrower hold than "the file is busy": #7654 was dispatched into
protocol.tsthis same round, as the second concurrent editor under the region exemption, because its region (thegetMetaItemsread merge) genuinely does not touch the refusal paths. Yours does. That is the whole distinction.Release condition: dispatched as soon as #8136 lands. State is
pm:queue, unassigned — ⛔ deliberately notpm:blocked, because this is a serialisation hold, not an upstream dependency; the card needs the file free, not #8136's outcome.Traps already recorded on this card — read them before implementing, they are still current:
protocol.ts:9161's comment about an ordinaryoutcome: 'allowed'row postdates this card — read that branch first; part of the defect may already be closed, andpremise_still_valid: falsewould be a legitimate delivery.ExtendedOperation's'publish'— that is thesys_metadata_historytable, a different table from the audit trail this card describes./meta/:type/:name/publishedresolves from the published store, not the code snapshot #8254's green run is not coverage here: its stub engine short-circuitssys_metadata_auditinserts, as its own dev stated unprompted.actor: 'system'—req.user/req.userIdare unset on the/metaPUT path #7749.Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
HOLD RELEASED. #8136 landed as
5905d7f51(PR #8334), so the card this was serialised behind is done andprotocol.tscarries no in-flight claim from this lane. State stayspm:queue, unassigned — dispatchable at the next batch.protocol.tsmoved substantially while you waited. Two PRs landed in it this round —5905d7f51(#8136, error/catch message construction) andd5031f6a1(#7654, thegetMetaItemsread merge) — on top of the four merges that preceded them. Every line number on this card and in its comments is stale by construction; re-locate by reading, and anchor on function names.assertLockAllowsWrite, which records on the deny path. That adjacency is exactly why you were held rather than run concurrently. Now that it has landed you are reading a settled file rather than racing one, but re-read those refusal paths before assuming this card's description of them still holds: apremise_still_valid: falseoutcome is a legitimate delivery.The traps recorded earlier all still stand — read them before implementing:
protocol.ts:9161's comment about an ordinaryoutcome: 'allowed'row postdates this card. Read that branch first — part of the defect may already be closed.ExtendedOperation's'publish'— that is thesys_metadata_historytable, not the audit trail this card describes./meta/:type/:name/publishedresolves from the published store, not the code snapshot #8254's green run is not coverage here: its stub engine short-circuitssys_metadata_auditinserts, as its own dev stated unprompted.actor: 'system'—req.user/req.userIdare unset on the/metaPUT path #7749.protocol.tsnow has four eligible candidates — this card, #8184, #8268 and #7860. The region-exemption cap is 3 concurrent editors with region-level declarations, and cards wanting the same lines serialise regardless. ⛔ Do not dispatch all four together.Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
Claim: PM loop round 2
Session:
session_012WMpuAfA2KSdDjGF6tm1bHBranch:
claude/issue-7748-lifecycle-audit-rowsWorktree:
objectstack-issue-7748Domain:
domain:metadataFile surface:
packages/metadata-protocol/src/protocol.ts— region: the audit-write sites only (the two allowed-outcome sites,assertLockAllowsWrite, and the 409 conflict route). ⛔ NOTassertFilterFieldsExist(#8296 is in it), ⛔ NOT the read-exit materialization stamps (#8268 is in them), ⛔ NOTsaveMetaItem's package door (landed as003feae65— read it, do not move it). (stop on breach; explain in the report)Container & model:
M,mode:subagent,model: opusSerial constraints cleared: third of three concurrent editors on
protocol.ts, at the region-exemption cap. Siblings: #8296 (assertFilterFieldsExist) and #8268 (read-exit stamps). ⛔ #7860 is NOT dispatched — that would be a fourth. Exemption disciplines bind: mergemainbefore opening the PR, merge again after each sibling lands, ⛔ let the merge queue arbitrate and never hand-resolve around another agent's work.Held since ~17:5xZ yesterday behind #8136, released when that landed (⚠️
5905d7f51); the traps recorded on the hold and hold-release comments (5270586423,5275146718,5277073360) are the brief and all still bind.protocol.tshas taken four landings while this waited —d5031f6a1,5905d7f51,003feae65and the wave-1 merges. Every line number on this card is stale by construction.5905d7f51(#8136) rewrote the error/catch message construction across this file, and your region includesassertLockAllowsWrite's deny path — which records the audit row it refuses on. Read that branch as it stands now before assuming this card's description of it holds.premise_still_valid: falseis a legitimate delivery.Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
{ "issue": 7748, "status": "done", "branch": "claude/issue-7748-lifecycle-audit-rows", "pr": "https://github.com/objectstack-ai/objectstack/pull/8401", "premise_still_valid": true, "summary": "Premise confirmed against origin/main by reading, not line numbers: publishMetaItem and rollbackMetaItem still reached recordMetadataAudit only via assertLockAllowsWrite (deny-path only, early return on allow), and all four 409 METADATA_CONFLICT sites wrote nothing. Added the allowed-outcome publish and rollback rows in the same position the two pre-existing sites (save, delete) use, and routed all four conflict sites through one new private helper recordOptimisticConflictAudit writing outcome:'denied', code:'metadata_conflict'. THE 9161 COMMENT IS A FALSE ALARM AND CLOSES NOTHING: relocated by content (now ~9385), it belongs to getEffectiveLock's #5706 fail-open catch and its phrase 'writes its ordinary outcome:allowed row' refers to the SAVE path's existing row — it is a remark about what the fail-open window costs the trail, not a publish/rollback audit write. Also measured: sys_metadata_audit already declares 'publish' and 'rollback' as operation picklist options, so the table was designed for these rows from the start and only the writes were missing. Issue body was HTML-escaped, not sanitizer-truncated — all five sections intact.", "tests": "New: packages/metadata-protocol/src/protocol.lifecycle-audit-rows.test.ts, 7 cases, all green. Its fake engine deliberately does NOT short-circuit the audit insert (the PR #8254 vacuity trap) and records attempted-vs-landed separately. REVERSE VERIFICATION, direction predicted before running and PREDICTION WAS WRONG — predicted 5 red / 2 green, MEASURED 6 red / 1 green on 'git checkout origin/main -- protocol.ts': 'expected [] to have a length of 1 but got +0', 'expected { save: 2 } to match object { save: 2, publish: 2, rollback: 1 }', 'expected [ save ] to include publish', 'expected false to be true'. The extra red is the best-effort-swallow case: under the defect no publish audit write is ATTEMPTED, so the failing table has nothing to reject — correct direction, header corrected to the measurement rather than the guess. Only the save control stayed green (the asymmetry that matters). Restored via git checkout from the branch commit; git status clean, so numbers were taken against shipping bytes. Suites: metadata-protocol 80 files/1170 tests green (re-run after merging main); downstream consumer sweep using the PREFIX filter '...@objectstack/metadata-protocol' (= consumers): objectql 196 files/3496 tests, rest 110 files/1817 tests, spec api/protocol.test.ts 51, plugin-email template-runtime-write 14 — those last two chosen deliberately because their fake engines drive publish/rollback with NO sys_metadata_audit branch, so a new insert could have collided in their row maps. Gates: all 45 check:* parsed out of the ESLint job plus check:i18n, check:type-check-coverage, check:agent-model-declared = 48/48 green. check:i18n first failed on an unmet prerequisite (workspace CLI not built in a fresh worktree; it states plainly that nothing was checked) and went green after 'turbo run build --filter=@objectstack/cli'. Re-derivation against actual changed paths surfaced four the dispatch list did not name — check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, scripts/check-changeset-no-major.mjs — all green. check:objectui-pin-fresh skipped per dispatch instruction (known stale local checkout; CI is authority).", "open_questions": [], "out_of_scope_findings": [ "filed as #8400: publishPackageDrafts writes no sys_metadata_audit rows at all — Studio's 'publish whole app' leaves the trail empty. Left out of this PR deliberately, not overlooked: it calls promoteDraftForPublish inside ONE engine.transaction(), so an audit row written there rolls back with the batch — including the refusal that caused the rollback, which is exactly the defect #7748 exists to close. Both pre-existing allowed-outcome sites write after their repository transaction has closed; matching them for the batch needs a Phase 2 placement off promoted[] plus its own test. Searched open issues first (keyword + file path): no duplicate. Filed unassigned and unlabeled for PM triage." ] }Notes the JSON cannot carry
Region fence held — no breach. Edits are confined to the audit-write sites: the
publishMetaItem/rollbackMetaItemallowed rows, the four 409 conflict catches, and one new private helper besideassertLockAllowsDelete. ⛔assertFilterFieldsExist(#8296), the read-exit materialization stamps (#8268) andsaveMetaItem's package door (003feae65) were read but not touched —git diff origin/main -- protocol.tsis+130lines, pure additions, zero deletions.Concurrent-editor discipline. Merged
origin/mainbefore opening the PR, as the third of three editors under the region exemption; the merge was clean with no conflicts inprotocol.ts, and the package suite was re-run green afterwards. Nothing was hand-resolved around a sibling's work. Happy to re-merge after #8296 or #8268 lands — the merge queue should arbitrate.On the two traps the card warned about, both confirmed rather than assumed: I did not read
ExtendedOperation's'publish'as coverage (that issys_metadata_history, a different table — the auditoperationcolumn is its own vocabulary, and it separately already declarespublish/rollbackoptions); and PR #8254's stubreturn { id: 'audit_skip' }is exactly what my harness refuses to do.One thing worth the PM's eye: the "best-effort" question the dispatch raised is real and is now pinned. A failed audit write is swallowed with a
console.warn, so "the row is missing" and "the row failed to write" are indistinguishable to a caller. The new suite separates them by recording every insert aimed at the table alongside the ones that landed — without that, a future regression that stopped attempting the write would look identical to an unprovisioned table and stay green.Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
Landed —
e15e8705onmainConfirmed by both readings: the merge event and the commit on
origin/main(11:28:46Z), never theauto_mergefield. PR #8401, closed automatically by itsFixestrailer;pm:dispatcheddropped.What shipped. The defect was an inversion:
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 409METADATA_CONFLICTrefusal is raised outside that helper and wrote nothing at all, leaving a caller repeatedly losing an optimistic-concurrency race indistinguishable, in the trail, from one who never tried.Now: a successful publish writes
operation: 'publish'/outcome: 'allowed', a successful rollback the same with the restored version named, both in the same position as the pre-existingsaveanddeleterows; and all four routes that can raise the 409 go through one helper,recordOptimisticConflictAudit, rather than four hand-written copies beside the four near-identical hand-written conflictErrors already there.sys_metadata_audithad always declaredpublishandrollbackasoperationoptions — the table was designed for these rows and the writes were simply never made.Three things worth carrying forward.
The reverse verification was wrong and said so. It predicted 5 red / 2 green and measured 6 red / 1 green, then recorded the mismatch and its cause in the test header rather than retro-fitting the prediction: the best-effort-swallow case is load-bearing on
auditAttempts.some(a => a.operation === 'publish'), and under the defect no publish audit write is ever attempted, so the failing table has nothing to reject. Only thesavecontrol stayed green — the asymmetry proving the fix did not disturb the one site that already worked.It found a third state nobody asked for.
recordMetadataAuditis best-effort by contract (ADR-0010 §3.6), so a missing row is ambiguous between nothing ever tried (this defect) and attempted and rejected (a provisioning problem). A test that only counts rows passes forever on the second. Recording attempts separately from landings keeps them apart, and the last case drives the rejected-table path explicitly.The vacuity trap was named, not stumbled into. Every other multi-table stub in this repo opens
insertwithif (table === 'sys_metadata_audit') return { id: 'audit_skip' }— correct elsewhere, fatal here, because it reports "no audit rows" identically before and after the fix. This harness persists them, and thesavecontrol exists to prove the number0means something.Residues:
bug/pm:queue, this lane) — batchpublishPackageDraftswrites no audit rows at all, so Studio's "publish whole app" leaves the trail empty. Deliberately excluded rather than overlooked: it promotes inside oneengine.transaction(), where an audit row rolls back with the batch — including the refusal that caused the rollback, which is the very defect this card closed. Needs a Phase 2 placement offpromoted[]and its own test.actor: 'system'—req.user/req.userIdare unset on the/metaPUT path #7749 — the actor stamped on these rows. Remains open, deliberately untouched.A premise question also resolved on the way: the
protocol.tscomment that appeared to claim publish/rollback rows already existed is a false alarm. Relocated by content, it belongs togetEffectiveLock's #5706 fail-open catch, and its "writes its ordinaryoutcome:allowedrow" refers to the save path. It closes nothing.Generated by Claude Code
os-project-manager commentedon Aug 16, 2026
Attribution note — the audit rows this card added inherit the #7941 rule
Recording this here because the #7941 ruling asks for it, and because this card is already closed (by PR #8401): no action is needed on it, and it is not being re-opened. This is a pointer for anyone reading the publish/rollback audit trail later.
PR #9119 implements the maintainer's ruling on #7941: the authenticated identity is the recorded actor on all five
/metawrite sites, andX-Actorno longer outranks it. The header limb was removed fromresolveMetaWriteActorrather than reordered, so a caller cannot choose the name an audit row records in any shape — including on the machine-write path, where there is no identity for the header to lose to.The publish, rollback and 409-denial rows this card added flow through that same single producer, so they inherit the rule automatically — there is nothing separate to change here:
sys_metadata_audit.actorandsys_metadata_history.recorded_byon a publish or rollback now name the principal the request was actually authorized as.X-Actoron every call. Re-running them after fix(rest): the authenticated identity is the recorded actor on /meta writes, X-Actor no longer outranks it (#7941) #9119 lands, those rows will name the authenticated caller, not the header's value. That is the new expected result, not a regression of this card's fix — the rows still appear, which is what this card was about.'system'/NULLdefaults still apply to genuine machine writes with no principal, unchanged.Relevant if this card's behaviour is ever re-verified from the QA checklist:
docs/qa/platform-checklist/areas/studio-authoring.jsonstill describes the actor as resolved "fromX-Actor/ the session identity", which is now stale. Filed separately and unassigned as #9118.Related: #7941 (the ruling), #7749 (the producer fix that made the precedence load-bearing), PR #9119.
Generated by Claude Code
Generated by Claude Code
fix(rest): attribute a bearer-authenticated metadata write to its cal…
fix(metadata-protocol): audit the allowed publish/rollback and the 40…
fix(metadata-protocol): audit the batch package publish in Phase 2, o…
fix(metadata-protocol): audit the pre-flight refusal of a package pub…