Skip to content

SysMetadataRepository.watch() never replays from sys_metadata_history — contract invariant 6 (resumability) is unimplemented in the repository backing every production metadata write #10842

Description

@os-elon

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:

const dispatch = (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:

  1. 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.
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions