Found by #10420, the card that ran SysMetadataRepository through the shared runRepositoryContractTests suite for the first time. 32 of 34 clauses pass; these two are the divergence. Filed rather than fixed inside that PR — the reasoning for that is the last section, and it is the part that needs a ruling.
The contract text
packages/metadata-core/src/repository.ts states this as invariant 6, and repeats it as a MUST on watch() itself:
* 6. **Resumability.** `watch(_, since)` MUST replay all events with
* `seq > since` before delivering live events.
/**
* Live event stream. The iterator MUST:
*
* - Replay all events with `seq > since` before yielding any new event.
What SysMetadataRepository actually does
packages/metadata-protocol/src/sys-metadata-repository.ts, watch(): the iterator registers a dispatch closure into this.watchers and nothing else. since is read in exactly one place, and it is a drop filter on live events, never a replay cursor:
constdispatch=(evt: MetadataEvent)=>{if(stopped)return;if(!self.matchesFilter(evt,filter))return;if(since!==undefined&&evt.seq<=since)return;
...No read of sys_metadata_history happens on subscribe. An event that committed before the subscription is unreachable through watch(), whatever since says — even though the repository holds a durable, per-org monotonic event_seq log and already reads it org-wide in nextEventSeq().
Measured — two clauses, two different faces of the one invariant
Run: packages/metadata-protocol, vitest run src/sys-metadata-repository.contract.test.ts (the file #10420 adds).
× MetadataRepository contract — SysMetadataRepository > watch / history >
watch(sinceSeq) replays subsequent events then goes live 5008ms
→ Test timed out in 5000ms.
× MetadataRepository contract — SysMetadataRepository > watch / history >
watch filters by type and name 207ms
→ expected +0 to be 1 // Object.is equality
The two faces are not the same call shape, and that distinction is the whole decision below:
since given a number.watch(filter, a.seq) must replay b before going live. It replays nothing, so the first next() never settles and the clause dies on the 5s timeout. This face is the literal MUST quoted above.since omitted.watch({org, type, name}) opened after two writes must still surface the one matching event. InMemoryRepository replays its whole matching log when since is undefined; FileSystemRepository passes the same clause. SysMetadataRepository yields nothing. This face is not written down anywhere in repository.ts — invariant 6 speaks only of seq > since, and with no since there is no such set. The "no since means replay everything" reading exists only as InMemoryRepository's implementation, and the shared suite silently depends on it.
This is #7992's shape one more time: one invariant, and the implementations that break it break different faces of it.
Why this was not fixed in the discovering PR
Not because it is hard to write — the replay itself is roughly one durable read (find('sys_metadata_history', { where: { organization_id } }), the same query nextEventSeq() already issues), the row-to-event mapping history() already contains, plus a dedup handoff into the live queue. It was left alone because of who is subscribed today, and because face 2 has no written answer.
Both production consumers subscribe with no since:
packages/metadata/src/metadata-manager.ts, startRepositoryWatch() — repo.watch({}). Every delivered event runs applyRepoEvent: invalidate the registry entry, invalidate listCache, re-emit to every watcher (ObjectQLPlugin, Studio HMR). This subscription is opened by setRepository(), i.e. at attach time.packages/metadata-core/src/cache.ts, MetadataCache.start() — repo.watch(this.watchFilter).
So implementing face 2 as InMemoryRepository implements it means: every setRepository() replays the entire sys_metadata_history for the org through HMR and cache invalidation. On a mature environment that is thousands of rows of "this changed" at attach time. That is a production behaviour change with a real flood risk, decided on a reading that the contract does not actually state.
Face 1 has the opposite risk profile: no production caller passes a numeric since today (the only forwarder is LayeredRepository, which passes through whatever it was given), so implementing it changes nothing that currently runs while satisfying the literal MUST.
The fork this needs a ruling on
- A — implement both faces, matching
InMemoryRepository exactly. One table, no exceptions, at the cost of the attach-time replay flood described above (which then probably needs a bounded/sinceSeq-seeded subscription at the two consumers). - B — implement face 1 only (numeric
since replays from the durable log), and amend repository.ts to state what watch() with no since owes — most likely "live events only", which would make face 2 a defect in the suite (it leans on unspecified behaviour) rather than in this repository. - C — amend the contract to carve engine-backed repositories out of invariant 6, and say what resumability consumers should use instead (
history() plus a seq cursor). This is the option that fragments the invariant table, and it should not win by default.
Recommendation: B. It satisfies the only sentence the contract actually writes down, costs zero production behaviour change, and forces the unwritten half to be written rather than inherited from whichever implementation happened to be read first. A is defensible but should not be adopted without deciding what the two no-since consumers do with a full replay; C is last because the invariant table's value is that it has no per-implementation columns.
Interim state on main (after #10420 lands)
The divergence is not hidden by a skip. ContractSuiteOptions.declaredDivergences makes the call site name this issue, and in exchange the suite runs a clause that pins the divergent behaviour — it reds the moment the repository starts replaying, so whoever fixes this is told to delete the declaration in the same PR. Shrink-only, audited in the fixing direction.
Provenance
Generated by Claude Code
Found by #10420, the card that ran
SysMetadataRepositorythrough the sharedrunRepositoryContractTestssuite for the first time. 32 of 34 clauses pass; these two are the divergence. Filed rather than fixed inside that PR — the reasoning for that is the last section, and it is the part that needs a ruling.The contract text
packages/metadata-core/src/repository.tsstates this as invariant 6, and repeats it as a MUST onwatch()itself:What
SysMetadataRepositoryactually doespackages/metadata-protocol/src/sys-metadata-repository.ts,watch(): the iterator registers adispatchclosure intothis.watchersand nothing else.sinceis read in exactly one place, and it is a drop filter on live events, never a replay cursor:No read of
sys_metadata_historyhappens on subscribe. An event that committed before the subscription is unreachable throughwatch(), whateversincesays — even though the repository holds a durable, per-org monotonicevent_seqlog and already reads it org-wide innextEventSeq().Measured — two clauses, two different faces of the one invariant
Run:
packages/metadata-protocol,vitest run src/sys-metadata-repository.contract.test.ts(the file #10420 adds).The two faces are not the same call shape, and that distinction is the whole decision below:
sincegiven a number.watch(filter, a.seq)must replaybbefore going live. It replays nothing, so the firstnext()never settles and the clause dies on the 5s timeout. This face is the literal MUST quoted above.sinceomitted.watch({org, type, name})opened after two writes must still surface the one matching event.InMemoryRepositoryreplays its whole matching log whensinceisundefined;FileSystemRepositorypasses the same clause.SysMetadataRepositoryyields nothing. This face is not written down anywhere inrepository.ts— invariant 6 speaks only ofseq > since, and with nosincethere is no such set. The "nosincemeans replay everything" reading exists only asInMemoryRepository's implementation, and the shared suite silently depends on it.This is #7992's shape one more time: one invariant, and the implementations that break it break different faces of it.
Why this was not fixed in the discovering PR
Not because it is hard to write — the replay itself is roughly one durable read (
find('sys_metadata_history', { where: { organization_id } }), the same querynextEventSeq()already issues), the row-to-event mappinghistory()already contains, plus a dedup handoff into the live queue. It was left alone because of who is subscribed today, and because face 2 has no written answer.Both production consumers subscribe with no
since:packages/metadata/src/metadata-manager.ts,startRepositoryWatch()—repo.watch({}). Every delivered event runsapplyRepoEvent: invalidate the registry entry, invalidatelistCache, re-emit to every watcher (ObjectQLPlugin, Studio HMR). This subscription is opened bysetRepository(), i.e. at attach time.packages/metadata-core/src/cache.ts,MetadataCache.start()—repo.watch(this.watchFilter).So implementing face 2 as
InMemoryRepositoryimplements it means: everysetRepository()replays the entiresys_metadata_historyfor the org through HMR and cache invalidation. On a mature environment that is thousands of rows of "this changed" at attach time. That is a production behaviour change with a real flood risk, decided on a reading that the contract does not actually state.Face 1 has the opposite risk profile: no production caller passes a numeric
sincetoday (the only forwarder isLayeredRepository, which passes through whatever it was given), so implementing it changes nothing that currently runs while satisfying the literal MUST.The fork this needs a ruling on
InMemoryRepositoryexactly. One table, no exceptions, at the cost of the attach-time replay flood described above (which then probably needs a bounded/sinceSeq-seeded subscription at the two consumers).sincereplays from the durable log), and amendrepository.tsto state whatwatch()with nosinceowes — most likely "live events only", which would make face 2 a defect in the suite (it leans on unspecified behaviour) rather than in this repository.history()plus a seq cursor). This is the option that fragments the invariant table, and it should not win by default.Recommendation: B. It satisfies the only sentence the contract actually writes down, costs zero production behaviour change, and forces the unwritten half to be written rather than inherited from whichever implementation happened to be read first. A is defensible but should not be adopted without deciding what the two no-
sinceconsumers do with a full replay; C is last because the invariant table's value is that it has no per-implementation columns.Interim state on
main(after #10420 lands)The divergence is not hidden by a skip.
ContractSuiteOptions.declaredDivergencesmakes the call site name this issue, and in exchange the suite runs a clause that pins the divergent behaviour — it reds the moment the repository starts replaying, so whoever fixes this is told to delete the declaration in the same PR. Shrink-only, audited in the fixing direction.Provenance
SysMetadataRepository— the implementation backing every production metadata write — is not run through the sharedrunRepositoryContractTestssuite #10420 (SysMetadataRepositoryintorunRepositoryContractTests), branchclaude/issue-10420-sysmetadata-contract-suite.origin/main@5f2e54cc66.Generated by Claude Code