fix(metadata-protocol): bump write epoch on remote metadata mutation receipt - #15043
Conversation
…receipt applyRemoteMetadataMutation converged a peer replica's registry on a peer's metadata.mutated cluster signal but never moved this replica's write epoch, so meta-overlay-cache's row set stayed "fresh" for the rest of its TTL. A read landing inside that residue window re-hydrated the just-deleted row straight back into the registry the bridge had just healed, converting a bounded ~30s residue into an unbounded one. Adds bumpWriteEpoch (meta-overlay-cache.ts), a structural sibling to readWriteEpoch that retires the cache without importing @objectstack/objectql, and calls it from applyRemoteMetadataMutation after registry convergence and before notifyMutationListenersLocal (#5109 invalidate-before-notify rule). Mirrors authz-invalidation-bridge.ts's epoch.bump('remote') on the identical substrate. Updates the three pins ruling A' authorizes in protocol.datasource-delete-prolongation.test.ts (two UNBOUNDED arms invert to bounded-at-0ms; the SCOPED-kernel arm's bound moves from TTL_MS to 0, with its rationale rewritten), plus unit coverage for bumpWriteEpoch in meta-overlay-cache.test.ts. Fixes #13609 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…mote-mutation-epoch-bump
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…shift pnpm check:system-context-census --fix, following our bumpWriteEpoch import addition in protocol.ts (row 21 shifted 1746 -> 1747). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 969e1a5864b92c4dfcf9617efb151edbc434573e && git checkout 969e1a5864b92c4dfcf9617efb151edbc434573e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d17f352b1bb3e78682559e6f09bc48e77fda4dd5 8d1f11e7f828feb27341b2575ec021ef27a70f47 && git checkout -B drift-repro d17f352b1bb3e78682559e6f09bc48e77fda4dd5 && git merge --no-ff 8d1f11e7f828feb27341b2575ec021ef27a70f47
node scripts/docs-audit/affected-docs.mjs --json d17f352b1bb3e78682559e6f09bc48e77fda4dd5
|
…mote-mutation-epoch-bump
…mote-mutation-epoch-bump
…read cache too #13609's bumpWriteEpoch call in applyRemoteMetadataMutation retires the sys_metadata overlay-row cache directly, on the same shared engine write epoch the authz.invalidated bridge already bumps. Two doc claims dated from before that fix and named authz.invalidated as the only non-local retirement path, with OS_METADATA_OVERLAY_CACHE_TTL_MS as the sole floor otherwise: - content/docs/concepts/metadata-lifecycle.mdx's cross-replica-sync note - content/docs/deployment/environment-variables.mdx's OS_METADATA_OVERLAY_CACHE_TTL_MS and OS_LOCALIZATION_CACHE_TTL_MS rows (the localization cache in packages/core/src/security/resolve-authz-context.ts reads the identical engine.writeEpoch seam, so it is retired by the same bump) Narrowed the lag claim to match: the TTL is now the floor only on a deployment with no cluster bridge attached at all (in-process `memory` driver), not on every deployment lacking the authz.invalidated bridge specifically. content/docs/kernel/cluster.mdx was flagged by the PR's own docs-drift-check bot (it names applyRemoteMetadataMutation) but makes no claim about the overlay-cache/write-epoch bound at all — left unchanged. No code, test, or changeset touched — the patch changeset already on this branch covers the behavior change; this is a documentation-only correction on top of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Independent review PASS, one finding closed — ready and armedAn independent reviewer re-derived every substantive claim rather than confirming it, returned PASS, and raised one non-blocking finding. That finding was fixed in-branch and is now closed. All 37 check runs on head What the review re-proved rather than acceptedClause-② = The refactor is behaviour-preserving, byte-for-byte. A′ clause by clause, proved from source: the bump sits at The three authorized pin changes, and no others. Four assertion-level readings across the two ⛔ arms inverted; the SCOPED-kernel arm moved Ablation reproduced end to end, with the restore proved by state — Gates: 66 commands derived off the merge base with no path arguments; the reviewer went further than the author and completed a full workspace build (71/71 tasks) so that The finding, and the fix that went past itThe reviewer found The fix round went one page further than the finding, correctly. A wider-tree grep turned up a third, unflagged instance of the same stale claim: And The corrections state what is now also true without implying anything false: the Standing constraints honoured: the live multi-node run stays declared NOT MEASURED; PR #13883's residual question (shipped in-process memory driver vs. a second defect in a real distributed driver) stays named, not resolved; reproduction detail stays withheld under the #9387 disclosure ruling; the pin file's docblock preserves both the QA observation and the source counter-evidence — annotated, never deleted. Changeset Generated by Claude Code |
Fixes #13609
Ruling executed — A′ (2026-09-03, comment 5528370129)
#14908 merged (
d5cbb44f3) before this branch was cut, so the fence condition is satisfied.The site (file:line, this branch)
packages/metadata-protocol/src/protocol.ts,applyRemoteMetadataMutation(private method, lines 5187–5220 on this branch):Placed exactly where A′ says: after the registry-convergence
if/else, beforenotifyMutationListenersLocal(#5109 invalidate-before-notify — a listener that re-reads throughgetMetaItemsmust not observe the healed registry through a still-stale overlay cache).The helper, and why it is not a direct import
packages/metadata-protocol/src/meta-overlay-cache.tsgainsbumpWriteEpoch, declared beside the existingreadWriteEpoch, both now built on one shared, un-exportedresolveWriteEpochSeamthat validates the full{ current, bump, subscribe }structural surface (mirrorsisWriteEpochLikefrom@objectstack/objectql, without importing it — this package must not depend on@objectstack/objectql):bumpWriteEpochis package-internal — not re-exported fromsrc/index.ts(matching the existingmetaOverlayCacheEntryCount's own "package-internal" precedent in the same file) — with its one caller beingapplyRemoteMetadataMutation. Clause-② re-derived on the final diff: no new exported symbol and no new key on a published payload —bumpWriteEpochnever crossesindex.ts, so the mechanical floor's "no" holds, matching the ruling's expectation but re-checked rather than assumed.Mirrors
authz-invalidation-bridge.ts:71'sepoch.bump('remote')on the identical write-epoch substrate (#11968) — the metadata cluster channel now gets the same bump the authz cluster channel already had; this closes an asymmetry between two existing paths, it does not add a new mechanism.The three authorized pin changes, before → after
All three live in
packages/metadata-protocol/src/protocol.datasource-delete-prolongation.test.ts(762 → ~790 lines; PR #14431's re-verification file, landed asa98b61b3e):serves(peer)→true;registry.listItems('datasource')→['billing_db']serves(peer)→false;registry.listItems('datasource')→[]prolongation→null(UNBOUNDED, past 10 windows);serves(peer)→trueprolongation→0;serves(peer)→falseprolongation→TTL_MS(30 000 ms)prolongation→0— bound moved, not loosened to a less-than-or-equal comparison; rationale rewritten in-line to say why (the overlay cache is the scoped kernel's only local source, and the bump retires it at convergence instead of letting it lapse)The four controls the ruling says stay as they are, do: Arm B's registry-heal assertion, the "door NOT read" bounded-by-one-TTL-window arm, Arm A's no-bridge control (still
UNBOUNDED), and the negative control (analytics_dbstill served,billing_dbserved iff!shape.attach) — all unchanged and all still passing.Test file's own docblock is updated in the same places the behaviour changed (the "Arms" narrative, "why the re-hydration happens" → "why it no longer does", the four-seam checklist's item 3, "NOT FIXED HERE" → "FIXED HERE"), while the methodology/topology/proxy-declaration sections are left untouched as accurate history.
packages/metadata-protocol/src/meta-overlay-cache.test.tsgets three new unit cases forbumpWriteEpochitself (accepts the full seam and returns the new value; declines the same wayreadWriteEpochdoes on no/partial seam; retires a live cache entry the same way a real engine write does) — direct coverage of the helper, independent of the end-to-end pin above.Ablation — declared before running, then run
Predicted, in writing, before mutating: removing the
bumpWriteEpoch(this.engine, 'remote')call turns exactly the three pins above RED — 5 assertion-level failures across 3 test cases — while the other 6 tests in the file stay GREEN (they never reach the receipt path, or never depended on the epoch).Mutation, confirmed on disk before running:
packages/metadata-protocol/src/protocol.ts's bump line replaced with a comment via a Python string-replace that asserted exactly 1 occurrence of the exact call-site text before replacing (a 0-hit replace would itself exit 0, so the count was asserted, not assumed);git diff --statshowed the expected 1-line change and agrep -cfor the removed anchor confirmed 0 occurrences before the test run.Observed (
pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.datasource-delete-prolongation.test.tsagainst the mutated source, no rebuild — this file resolves./protocol.jstosrc/protocol.tsdirectly):The 3 failures are exactly the 3 pins above (
expected true to be false,expected null to be +0,expected 30000 to be +0) — the predicted set, exactly.Restore, pinned to
HEAD(never a baregit checkout --, which restores from a possibly-polluted index):git checkout HEAD -- packages/metadata-protocol/src/protocol.ts, proved by state, not exit code —git diff HEADempty,git status --porcelainempty, andgit hash-objecton the restored file equal togit rev-parse HEAD:packages/metadata-protocol/src/protocol.ts(both50125f18004684b9714cf1abcf00fe02613cf5d9). Re-ran the same test file against the restored source:Tests 9 passed (9)again.The mutation/restore script ran under a shell
trapon EXIT, INT and TERM that ran the restore command, anchored toREPO_ROOT="$(git rev-parse --show-toplevel)"(absolute path), so a cap-kill mid-mutation would still have restored the tree.Live multi-node — NOT MEASURED, declared
No live cluster driver or multi-process deployment is reachable from this container. Everything measured above ran on the in-process two-instance harness
protocol.datasource-delete-prolongation.test.tsalready declares as the accepted proxy (PR #14431's shape: two protocol instances over one sharedsys_metadatastore, each with its own registry/overlay-cache/write-epoch, joined by a real cross-instance transport that delivers every publish to every subscriber, publisher included).What the proxy leaves open, unchanged by this round: whether the QA deployment that originally reported the prolongation was running the shipped in-process memory cluster driver (no cross-process delivery — nothing was listening) or hit a second defect in a genuinely distributed driver (PR #13883's residual question). Reproduction detail stays withheld under the #9387 disclosure ruling.
Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no path args) derived the change set from git each time, re-run after every meaningful diff change:origin/mainmerged in — main advances continuously,git fetch+ re-derive run repeatedly rather than once): 42 commands — all 42 run; 38 pass, 1 real finding fixed (check-system-context-census.mjs: our import addition shifted a line-anchor by one line; repaired with the gate's own--fix, never by hand), 3 legitimatePREREQUISITE NOT MET/NOT MEASURED (all 3 are whole-tree gates needing artifacts this local run does not build:check-test-completeness.mjswants a savedturbo run testlog;check:pm-half-states's siblingcheck-half-states.mjsneeds GitHub census read access;check:dual-build-cjs-loadsneeds a full-repopnpm buildacross 49+ packages).content/docs/permissions/system-context.mdx, which pulled in the full docs-family gate set on the next derivation: 66 commands total, 24 newly implicated, all run — 22 pass immediately, 2 needed a rebuild first:@objectstack/spec'scheck:docs(spec'ssrc/had moved under theorigin/mainmerge, per AGENTS.md §9/§10 — itsjson-schema/distwere stale relative to the merged source, not relative to anything we wrote —pnpm --filter @objectstack/spec buildfixed it) andcheck:skill-examples(its client-SDK surface additionally needed@objectstack/client-reactbuilt, which pnpm's dependency selector resolved to 36 of 80 workspace packages via that package's devDependencies — unrelated collateral, not a sign of anything wrong on our diff). Both green after their respective rebuilds; re-ran with no path filter afterward and confirmed the derived command list was unchanged (no new families appeared).Full verdict-by-verdict list (all 66 derived commands, run against the final tree) is in the dev report JSON. Final gate union run at
28cc78412/ca9728a8a(the second commit is the census--fixalone);pnpm --filter @objectstack/metadata-protocol testandtypecheckre-run once more atca9728a8a(final HEAD): 160 test files / 2 skipped, 2366 tests / 10 skipped, all pass; typecheck clean.node scripts/pm/check-governed-merges.mjs --testagainst the final changed-path list → NOT governed (0 of 6 paths hitGOVERNED_SURFACES) — ordinary queue landing applies.Changeset
.changeset/metadata-protocol-remote-mutation-epoch-bump.md—patchfor@objectstack/metadata-protocol(the only package touched). Not a declared-breaking change (no removed/renamed authorable key, no export change), so no ADR-0087 marker is required — confirmed bycheck-adr-0087-registration.mjs's own verdict: "this PR adds no declared-breaking changeset."Docs correction (follow-up round, 2026-09-03) — the non-blocking review finding
Independent review passed every substantive point on the fix above and named one non-blocking finding:
bumpWriteEpochnow retires themeta-overlay-cacheoverlay-read cache directly, on receipt ofmetadata.mutated— a behavior two hand-written doc passages, written before this fix, did not describe. Both namedauthz.invalidatedas the only channel that retires that cache on a peer, withOS_METADATA_OVERLAY_CACHE_TTL_MSas the sole floor otherwise. That is now the wrong direction for the residue this PR closes.Fixed, narrowing the paragraph's structure (still correct: there are several ways the cache gets retired, TTL is still the floor when none of them fire) rather than rewriting it:
content/docs/concepts/metadata-lifecycle.mdx— the "Peers re-read from the shared database has one bound worth naming" callout. Added themetadata.mutatedreceipt path (applyRemoteMetadataMutation→bumpWriteEpoch) alongsideauthz.invalidatedas a retirement path, and narrowed "a deployment with no distributed cluster driver attached" to "no cluster bridge attached at all — the in-processmemorydriver."content/docs/deployment/environment-variables.mdx— theOS_METADATA_OVERLAY_CACHE_TTL_MSrow, same correction. Also found the identical claim on theOS_LOCALIZATION_CACHE_TTL_MSrow (leg C of Design: cross-request caching for the authenticated request path (tranche 2 of #10757) — write-invalidation-first, short-TTL fallback, configurable staleness window #11633): its cache is keyed on the exact same sharedengine.writeEpochseam (packages/core/src/security/resolve-authz-context.ts'sreadWriteEpoch(ql), confirmed againstwrite-epoch.ts's own "ONE invalidation signal... shared" contract) — so it is equally retired by the new bump, and its row named the same now-outdated bound. Fixed both rows.content/docs/kernel/cluster.mdxwas flagged by this PR's owndocs-drift-checkbot (comment 5530470960, via theapplyRemoteMetadataMutationanchor) — read in full and left unchanged: its Lane 2 (metadata.mutated) section describes registry convergence and re-read semantics only, and makes no claim anywhere about the overlay-cache / write-epoch staleness bound this diff moves. Flagged is not falsified.Wider-tree grep for every claim this diff moves (
OS_METADATA_OVERLAY_CACHE_TTL_MS,authz.invalidated, "overlay cache", "write epoch",metadata.mutated,bumpWriteEpoch,applyRemoteMetadataMutation) acrosscontent/docs/**, with a positive control, found no other hits beyond the three pages above (references/api/protocol.mdx's "overlay" mentions are about org-scope precedence and ETags, unrelated to cross-replica cache timing;kernel/services-checklist.mdx'sgetMetaItemsmention is an API-surface checklist row, unrelated to caching semantics).No code, test, or changeset touched by this follow-up — the existing
patchchangeset for@objectstack/metadata-protocolalready covers the behavior change; a documentation correction adds nothing to it. Gates re-derived on the final file list (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no path args): 67 commands (one more than the 66 above —check:overlay-whitelist-table, newly implicated by themetadata-lifecycle.mdxedit) — all 67 run at the final pushed head; 63 pass, 4 legitimatePREREQUISITE NOT MET(same shape as above, pluscheck:type-check-debt, all needing a full-repo build this container doesn't have).check-governed-merges.mjs --teston the full 8-path change set → still NOT governed.🤖 Generated with Claude Code
https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68