feat(telemetry): W2a-1 — SOURCE-BOUND generation identity at the activity store (store half)

getLatestForNode compares the recorded vs live occupant-tenure generation_uuid. The emitting
occupant's generation is CARRIED SOURCE-BOUND on the hook (the producer/relay supplies it at fire
time; the route ingests body.generation) — NEVER inferred from record-time state, which mis-attributes
a DELAYED prior-occupant hook to the successor occupant (a timing/boot_at inference is unsound at
SQLite 1-second precision + cross-host clock skew).

Read verdicts:
- carried != live -> unknown/generation_mismatch/stale, provenance resolved (REFUSED; dead-tenure
  evidence; same-second-immune because nothing is timed).
- carried == live (incl. a same-native relaunch continuation) -> fresh, provenance resolved.
- null on EITHER side -> unknown + a distinct reason (generation_unresolvable = live null /
  generation_unverifiable = carried none) + stale, generationProvenance='unresolved' ALONGSIDE
  (never fresh; delivered-labelled).
- resolver THROWS -> unknown/generation_resolver_error (degrade; never crashes, never fresh).
- no resolver -> legacy, no label.

Scope: STORE HALF + route ingestion + a SessionTransport advisory blast-radius fix.
SessionTransport.diagnoseProducerLink now distinguishes the four generation reasons from clock-stale
BEFORE the clock-stale fallback (a fresh generation verdict is stale:true at age ~0; collapsing it to
"beyond the store window / seat quiet" mislabelled producer-not-landed / dead-tenure as a DARK seat).

INERT + OBSERVABLE: the relay/env producer-carry is a FILED PREREQUISITE
(qitem-20260808195950-73d8311f); until it lands every hook carries null -> generation_unverifiable
-> detection INERT (sound, never false-fresh). generation_unverifiable is the MECHANISM (carried
null), NOT a single cause: producer-not-wired OR the emitting occupant had no tenure at fire time
(mint-race / pre-060 upgrade). Reader rule: ANY resolved/generation_mismatch => producer wired
(live); ALL generation_unverifiable => likely-not-wired but not proof (a fleet in mint-race/upgrade
looks the same) — disambiguate by tenure. Consumer verify-demotion is the separate tap follow-on
(qitem-20260808183747-f7f04662).

A structural guard pins exactly one production construction site AND that it injects the resolver.
VM-only (mm2 has no occupant_tenures table until 0.5.1). Upgrade-transition: migration 060 at
startup + mint-at-register => already-running seats read unknown until re-register.

Restacked onto exact main 045dea26 (was 4 commits behind at c9e2120c0). (B) receipt: the 8 store
files' per-file patch-ids are IDENTICAL pre/post restack — store logic unchanged; only startup.ts
merged (my source-bound construction site + main's watchdog auto-registration, non-overlapping,
auto-merged; the construction-site guard re-earned at the tip).

Evidence (measured at the restacked tip): full daemon matrix 2-fail/7105-pass, tsc:0. The only 2
fails are the seat-handover-model-fidelity + seat-handover-cutover e2e, PROVEN NOT MINE by a fresh
A/B AT THIS BASE: they fail with IDENTICAL signatures (successor_create_failed /
graceful-handover-false) at exact main 045dea26 WITHOUT this change — a structured deterministic
handover-create failure, not a timeout; the definitive uncontended real-vs-flake confirmation is
QA/operator domain (the box must drain). Store 17/17 (incl. the same-second carried-A-vs-live-B
mismatch + resolver-exception degrade + real-SessionRegistry composition); provider-c4 30/30;
sessions-routes 41/41; send-prompt-guard advisory pin (fresh generation_unverifiable -> generation-
distinct advisory, never clock-stale).

Mechanism note (routed to PM by dev50-planner): source-bound beats the locked rev-2 88b9a3bc line
112 record-time resolution (the delayed-hook mislabel) — a build-discovered refinement toward
correctness.

Review status: dev50-guard round-3 IN FLIGHT — mechanism shape approved; the diagnoseProducerLink
blocker fixed at this SHA; clearance re-requested on the restacked SHA. dev50-planner + orch-advisor
prior gates cleared; orch-advisor merge-desk restack/receipt requirements addressed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-08-09 09:02:32 +00:00
1 parent 497a85c161
commit 1e41c0cea2
11 files changed
+608 -2

No files matched your search

@@ -11,6 +11,11 @@ export interface HookActivityInput {
hookEvent: string;
subtype?: string | null;
occurredAt?: string | null;
/** W2a-1 — the EMITTING occupant's generation, CARRIED source-bound on the hook (the producer/relay
* supplies it at fire time; the route ingests it). NOT inferred from record-time state — that
* inference mis-attributes a delayed prior-occupant hook to the live occupant. Absent (producer not
* yet wired) ⇒ stamped null ⇒ unresolved at read (never false-fresh). */
generation?: string | null;
}
export type RecordHookActivityResult =
@@ -22,6 +27,13 @@ interface AgentActivityStoreDeps {
eventBus: EventBus;
now?: () => Date;
freshnessMs?: number;
/** W2a-1 — resolve the LIVE occupant generation for a node (the shipped occupant-tenure
* generation_uuid), or null when UNKNOWN. Injected (not self-constructed) so the store stays
* decoupled from SessionRegistry and unit-testable with a fake. When ABSENT the store applies no
* generation gate — legacy clock-only freshness — so the existing callers are unchanged until the
* producer wiring injects the real resolver. Resolution is SYNCHRONOUS (a better-sqlite3 read),
* which is why the gate lives inline in the sync read path with no signature churn to 9 callers. */
resolveOccupantGeneration?: (nodeId: string) => string | null;
}
interface SessionLookupRow {
@@ -40,12 +52,14 @@ export class AgentActivityStore {
private readonly eventBus: EventBus;
private readonly now: () => Date;
private readonly freshnessMs: number;
private readonly resolveOccupantGeneration?: (nodeId: string) => string | null;
constructor(deps: AgentActivityStoreDeps) {
this.db = deps.db;
this.eventBus = deps.eventBus;
this.now = deps.now ?? (() => new Date());
this.freshnessMs = deps.freshnessMs ?? AGENT_ACTIVITY_FRESHNESS_MS;
this.resolveOccupantGeneration = deps.resolveOccupantGeneration;
}
recordHookEvent(input: HookActivityInput): RecordHookActivityResult {
@@ -68,12 +82,22 @@ export class AgentActivityStore {
const sampledAt = this.now().toISOString();
const eventAt = parseTimestamp(input.occurredAt) ?? sampledAt;
// W2a-1 — SOURCE-BOUND stamp: the emitting occupant's generation is CARRIED on the hook (supplied
// by the producer/relay at fire time, ingested by the route), NEVER inferred from record-time
// state. A delayed prior-occupant hook recorded after a new tenure minted carries its OWN (prior)
// generation, so the read detects a mismatch rather than crediting it to the live occupant — and
// this is immune to boot_at precision / clock skew because nothing is timed. Absent (the relay/env
// producer-carry is a filed prerequisite, inert until it lands) ⇒ null ⇒ unresolved at read (the
// P21 claimed-era pattern: absence recorded as its own state, never false-fresh). No record-time
// resolver call — the live-gen resolver is used only at READ, for the comparison.
const generation = input.generation ?? null;
const activity = normalizeHookActivity({
runtime: input.runtime ?? session.runtime,
hookEvent: input.hookEvent,
subtype: input.subtype ?? null,
sampledAt,
eventAt,
generation,
});
const event = this.eventBus.emit({
@@ -108,6 +132,49 @@ export class AgentActivityStore {
if (input.sessionName && payload.sessionName !== input.sessionName) return null;
const referenceTime = input.now ?? this.now();
// W2a-1 — compare-at-read against the shipped occupant-tenure generation_uuid (pm ruling
// 2026-08-08, the fully-specified Variant C):
// (1) both generations known and DIFFERENT ⇒ MISMATCH: positive evidence the claim belongs to a
// prior, dead tenure ⇒ unknown + `generation_mismatch`, stale:true, provenance RESOLVED —
// REFUSED, so no dead tenure is honored as the live seat (detection fires).
// (2) both known and EQUAL (incl. a same-native-session relaunch — the ledger treats it as a
// continuation with no new generation) ⇒ provenance RESOLVED, deliver normally (fresh).
// (3) null on EITHER side ⇒ UNRESOLVABLE: ABSENCE of evidence, not a dead-tenure finding. State
// UNKNOWN + its distinct reason, stale:true — NEVER fresh (fresh is a positive liveness claim
// the seat has not earned; marking null-tenure fresh is bare-attribution one field over). The
// STORE delivers the row carrying generationProvenance='unresolved' at the ACTIVITY layer. The
// tap's discard-condition change that turns this label into a still-flowing consumer row is a
// FOLLOW-ON (qitem-20260808183747-f7f04662, verify-demotion) — NOT this fold; in the INTERIM
// the unchanged tap still discards it (a bounded regression whose exposure is the mint-race
// transient; mm2 is unaffected — its daemon carries no occupant_tenures table until 0.5.1).
// Ignorance and evidence get DIFFERENT verdicts; the two paths must not collapse.
// Gate runs only when a resolver is injected (else legacy clock-only, no label).
let generationProvenance: "resolved" | "unresolved" | undefined;
if (this.resolveOccupantGeneration) {
let liveGeneration: string | null;
try {
liveGeneration = this.resolveOccupantGeneration(nodeId);
} catch {
// Resolver EXCEPTION (e.g. a transient ledger/db fault) ⇒ DEGRADE to a distinct unknown
// branch: never crash the read, never render fresh. Distinct from a clean null (unresolvable).
return this.generationDegraded(activity, referenceTime);
}
const recordedGeneration = activity.generation ?? null;
if (liveGeneration !== null && recordedGeneration !== null) {
if (recordedGeneration !== liveGeneration) {
return this.generationMismatch(activity, referenceTime);
}
generationProvenance = "resolved";
} else {
return this.generationUnresolved(
activity,
referenceTime,
liveGeneration === null ? "generation_unresolvable" : "generation_unverifiable",
);
}
}
const eventTime = activity.eventAt ? Date.parse(activity.eventAt) : NaN;
if (Number.isFinite(eventTime) && referenceTime.getTime() - eventTime > this.freshnessMs) {
return {
@@ -118,6 +185,7 @@ export class AgentActivityStore {
sampledAt: referenceTime.toISOString(),
fallback: false,
stale: true,
generationProvenance,
};
}
@@ -126,6 +194,67 @@ export class AgentActivityStore {
sampledAt: referenceTime.toISOString(),
fallback: false,
stale: false,
generationProvenance,
};
}
/** W2a-1 — the dead-tenure verdict: the claim's occupant generation is KNOWN and DIFFERENT from the
* live one, so it must not be honored as the live seat. Returns unknown + `generation_mismatch`,
* stale:true (detection fires), provenance `resolved` (both generations WERE resolved — they simply
* differ). This is the ONLY generation case that abstains; a null on either side is UNRESOLVED
* provenance and is DELIVERED labelled, never routed here. */
private generationMismatch(activity: AgentActivity, referenceTime: Date): AgentActivity {
return {
...activity,
state: "unknown",
reason: "generation_mismatch",
generationProvenance: "resolved",
evidenceSource: "runtime_hook",
sampledAt: referenceTime.toISOString(),
fallback: false,
stale: true,
};
}
/** W2a-1 — the UNRESOLVABLE-provenance verdict (ABSENCE of evidence, not a dead-tenure finding).
* State UNKNOWN + a distinct reason + stale:true so no consumer reads it as verified-live (fresh is
* a positive liveness claim). The STORE delivers the row carrying generationProvenance:"unresolved"
* at the ACTIVITY layer; turning that label into a still-flowing CONSUMER row is the tap FOLLOW-ON
* (verify-demotion, qitem-20260808183747-f7f04662) — the unchanged tap still discards it in the
* INTERIM (a bounded regression, mint-race-transient exposure; mm2 has no occupant_tenures table
* until 0.5.1). Kept DISTINCT from generationMismatch: ignorance and evidence get different verdicts,
* and the paths must not collapse — at the store now, and at the tap when the follow-on lands. */
private generationUnresolved(
activity: AgentActivity,
referenceTime: Date,
reason: "generation_unresolvable" | "generation_unverifiable",
): AgentActivity {
return {
...activity,
state: "unknown",
reason,
generationProvenance: "unresolved",
evidenceSource: "runtime_hook",
sampledAt: referenceTime.toISOString(),
fallback: false,
stale: true,
};
}
/** W2a-1 — the resolver-EXCEPTION verdict: the live-generation resolver threw (a transient ledger/db
* fault). DEGRADE to unknown + a DISTINCT reason (`generation_resolver_error`) + stale:true, so the
* read never crashes and the claim is never rendered fresh; provenance `unresolved`. Kept separate
* from a clean null (unresolvable) so a resolver FAULT is distinguishable from an honest ABSENCE. */
private generationDegraded(activity: AgentActivity, referenceTime: Date): AgentActivity {
return {
...activity,
state: "unknown",
reason: "generation_resolver_error",
generationProvenance: "unresolved",
evidenceSource: "runtime_hook",
sampledAt: referenceTime.toISOString(),
fallback: false,
stale: true,
};
}
@@ -171,6 +300,7 @@ function normalizeHookActivity(input: {
subtype: string | null;
sampledAt: string;
eventAt: string;
generation?: string | null;
}): AgentActivity {
const rawEvent = input.hookEvent;
const rawSubtype = input.subtype;
@@ -222,6 +352,7 @@ function normalizeHookActivity(input: {
runtime,
fallback: false,
stale: false,
generation: input.generation ?? null,
};
}
@@ -1171,6 +1171,28 @@ export class SessionTransport {
if (!latest || latest.evidenceSource !== "runtime_hook") {
return `daemon-ingest link DOWN — no activity hook has ever been received for this seat; the ingest is rejecting posts (token mismatch → 401, or ingest unconfigured → 503) or Codex hook-trust is uncleared. Verify the seat was OpenRig-launched with hook-trust cleared`;
}
// W2a-1 — a GENERATION verdict is stale:true but the hook is RECENT (age ~0); collapsing it to
// "beyond the store window / seat quiet" mislabels producer-not-landed / dead-tenure as a DARK
// seat and defeats the inert-visible differentiation. Distinguish the generation cause explicitly
// BEFORE the clock-stale fallback. generation_unverifiable is the INERT signal (producer-carry not
// yet wired — sound, not dark); the others name a real generation condition, not a quiet seat.
if (latest.stale === true && typeof latest.reason === "string" && latest.reason.startsWith("generation_")) {
const ageS = latest.eventAt ? Math.round((this.now().getTime() - Date.parse(latest.eventAt)) / 1000) : null;
const age = ageS !== null ? `${ageS}s ago` : "recently";
switch (latest.reason) {
case "generation_unverifiable":
// Carried generation was null — the MECHANISM, not a single cause: EITHER the source-bound
// producer-carry (relay/env) is not yet wired (inert), OR the emitting occupant had no tenure
// at fire time (mint-race / pre-060 upgrade-transition). Sound, not dark; not a quiet seat.
return `producer link OK — a recent hook exists (${age}) but carried NO occupant generation; either the source-bound producer-carry (relay/env) is not yet wired, OR the emitting occupant had no tenure at fire time (mint-race / pre-060 upgrade). Generation UNVERIFIABLE, not a quiet seat`;
case "generation_unresolvable":
return `producer link OK — a recent hook exists (${age}) but the LIVE occupant generation could not be resolved (no tenure row); generation UNRESOLVABLE, not a quiet seat`;
case "generation_mismatch":
return `producer link OK — a recent hook exists (${age}) but it belongs to a PRIOR occupant generation (a dead tenure), not this live occupant; the seat is NOT quiet`;
case "generation_resolver_error":
return `producer link OK — a recent hook exists (${age}) but the occupant-generation resolver errored; generation verdict DEGRADED, not a quiet seat`;
}
}
if (latest.stale === true) {
const ageS = latest.eventAt ? Math.round((this.now().getTime() - Date.parse(latest.eventAt)) / 1000) : null;
return `producer link OK but STALE — the last activity hook arrived ${ageS !== null ? `${ageS}s ago` : "long ago"} (beyond the store window); the seat has gone quiet or its hooks stopped firing`;
+13
View File
@@ -443,6 +443,19 @@ export interface AgentActivity {
runtime?: string | null;
fallback?: boolean;
stale?: boolean;
/** W2a-1 — the occupant-tenure generation_uuid in force when this claim was recorded — the per-tenure
* occupant identity that CHANGES for a new occupant (a handover/swap to a different occupant mints a
* new generation) and persists only WITHIN a single tenure (a same-native-session relaunch is a
* continuation, same generation); null = UNKNOWN at record time. node_id, NOT this, is what is stable
* across handover — that is exactly why comparing generation detects a dead tenure. */
generation?: string | null;
/** W2a-1 — the read-side provenance verdict for the occupant generation (present only when the
* read resolved through an injected generation resolver): `resolved` = both the recorded and the
* live generation were known (equal here; a KNOWN mismatch returns state:"unknown" instead);
* `unresolved` = null on either side, so the claim is DELIVERED carrying this honest label rather
* than dropped or bare-attributed (pm ruling 2026-08-08, the P21 claimed-era pattern). Absent when
* no resolver is wired (legacy clock-only path). */
generationProvenance?: "resolved" | "unresolved";
}
export interface NodeRecoveryGuidance {
+4
View File
@@ -126,6 +126,10 @@ activityRoutes.post("/hooks", async (c) => {
hookEvent: typeof body.hookEvent === "string" ? body.hookEvent : "",
subtype: stringOrNull(body.subtype),
occurredAt: stringOrNull(body.occurredAt),
// W2a-1 — source-bound emitting generation, carried by the producer/relay. Until the relay/env
// injection lands (a filed prerequisite), this is absent ⇒ stamped null ⇒ unresolved at read
// (detection inert-but-sound; never false-fresh).
generation: stringOrNull(body.generation),
});
if (!result.ok) {
+10 -1
View File
@@ -895,7 +895,16 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// (ContextUsageStore is constructed above, ahead of ClaimService, for FR-3.)
const whoamiService = new WhoamiService({ db, rigRepo, sessionRegistry, transcriptStore, contextUsageStore });
const nodeCmuxService = new NodeCmuxService(rigRepo, sessionRegistry, cmuxAdapter, tmuxAdapter);
const agentActivityStore = new AgentActivityStore({ db, eventBus });
// W2a-1 — producer wiring: the live occupant generation resolves synchronously from the shipped
// occupant-tenure ledger. generation_uuid CHANGES for a new occupant and persists only within one
// tenure (a same-session relaunch is a continuation with no new generation); node_id is the stable-
// across-handover key, not this. null = UNKNOWN, honored by the store as abstain (never a stale claim
// rendered live). No tmux exec — a better-sqlite3 read on the hot path.
const agentActivityStore = new AgentActivityStore({
db,
eventBus,
resolveOccupantGeneration: (nodeId) => sessionRegistry.currentOccupantTenure(nodeId)?.generationUuid ?? null,
});
const { SeatAttentionReconciler } = await import("./domain/seat-attention-reconciler.js");
const seatAttentionReconciler = new SeatAttentionReconciler({
sessionRegistry, eventBus, agentActivityStore, db,
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
// W2a-1 STRUCTURAL GUARD (A4 sixth-site shape).
//
// The generation gate is an OPTIONAL dependency: a construction site that omits
// `resolveOccupantGeneration` silently falls to the legacy clock-only path and DISABLES the
// generation detector — with nothing failing. "One production site, resolver injected" is true today
// (startup.ts) but unenforced tomorrow. This pins it: production `src/` must contain EXACTLY ONE
// `new AgentActivityStore(` and that site must inject the resolver. A second site, or a naked one,
// fails LOUD here rather than turning the detector off in the dark.
//
// (Direct constructions in `test/` intentionally omit the resolver to exercise the legacy path; they
// are excluded because they are prod-unreachable by construction — the whole reason this guard exists.)
describe("AgentActivityStore — single production construction site (W2a-1)", () => {
const srcDir = fileURLToPath(new URL("../src", import.meta.url));
function walkTs(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return walkTs(full);
return entry.isFile() && full.endsWith(".ts") ? [full] : [];
});
}
it("has exactly one production construction site, and it injects resolveOccupantGeneration", () => {
const sites: Array<{ file: string; injectsResolver: boolean }> = [];
for (const file of walkTs(srcDir)) {
const text = fs.readFileSync(file, "utf8");
const re = /new AgentActivityStore\(/g;
let match: RegExpExecArray | null;
while ((match = re.exec(text)) !== null) {
// Inspect a bounded window of the constructor-arg object literal for the resolver key.
const window = text.slice(match.index, match.index + 400);
sites.push({
file: path.relative(srcDir, file),
injectsResolver: /resolveOccupantGeneration/.test(window),
});
}
}
expect(
sites,
`expected exactly ONE production construction site of AgentActivityStore; got ${JSON.stringify(sites)}`,
).toHaveLength(1);
expect(
sites[0]?.injectsResolver,
`the sole construction site (${sites[0]?.file}) must inject resolveOccupantGeneration — a naked site ` +
`silently disables the generation gate (legacy clock-only path)`,
).toBe(true);
});
});
@@ -225,3 +225,295 @@ describe("AgentActivityStore", () => {
});
});
});
// W2a-1 — GENERATION IDENTITY (SOURCE-BOUND). A claim from a PRIOR occupant generation must not be
// honored as the live seat. The emitting occupant's generation is CARRIED source-bound on the hook (the
// producer/relay supplies it at fire time; the route ingests it) — NEVER inferred from record-time state
// (that inference mis-attributes a DELAYED prior-occupant hook, and timing/boot_at is unsound at 1s
// precision + clock skew). The READ resolves the LIVE generation (session-registry currentOccupantTenure)
// and compares. Cases: (1) carried != live ⇒ MISMATCH — unknown/generation_mismatch/stale, provenance
// RESOLVED, REFUSED (positive dead-tenure evidence; immune to timing — same-second is still a mismatch);
// (2) carried == live (incl. a same-native relaunch continuation) ⇒ fresh, provenance resolved; (3) null
// on EITHER side ⇒ UNRESOLVABLE (absence of evidence): unknown + a DISTINCT reason (unresolvable = live
// null / unverifiable = carried none) + stale — NEVER fresh — with generationProvenance='unresolved'
// riding alongside so the row is DELIVERED (tap verify-demotion is a follow-on). The carried-none case
// is the INERT-until-producer state: sound, never false-fresh, detection off until the relay/env carry
// lands (a filed prerequisite); (4) no resolver ⇒ legacy, no label; (5) resolver EXCEPTION ⇒ degrade
// (generation_resolver_error). Ignorance and evidence get different verdicts and must not collapse.
describe("AgentActivityStore — generation identity (W2a-1)", () => {
let db: Database.Database;
let rigRepo: RigRepository;
let sessionRegistry: SessionRegistry;
let eventBus: EventBus;
beforeEach(() => {
db = createFullTestDb();
rigRepo = new RigRepository(db);
sessionRegistry = new SessionRegistry(db);
eventBus = new EventBus(db);
});
afterEach(() => {
db.close();
});
function seedGenSession() {
const rig = rigRepo.createRig("gen-rig");
const node = rigRepo.addNode(rig.id, "dev.qa", { runtime: "codex" });
const sessionName = "dev-qa@gen-rig";
const session = sessionRegistry.registerSession(node.id, sessionName);
sessionRegistry.updateStatus(session.id, "running");
sessionRegistry.updateBinding(node.id, { tmuxSession: sessionName, attachmentType: "tmux" });
return { node, sessionName };
}
// (1) DELAYED PRIOR-OCCUPANT / SAME-SECOND — the atom's whole case, closed by SOURCE-BINDING, not
// timing. A hook CARRYING a prior generation (gen-A), read while the live occupant is gen-B, is a
// MISMATCH — even in the same wall-clock second (nothing is timed; boot_at 1s precision + clock skew
// are irrelevant because the generation is carried, not inferred). Never rendered fresh for the live
// occupant. This is the delayed prior-occupant hook that the record-time-inference version mis-credited.
it("carried prior generation vs live (same-second) ⇒ unknown/generation_mismatch, never fresh", () => {
const { node, sessionName } = seedGenSession();
const store = new AgentActivityStore({
db,
eventBus,
now: () => NOW,
resolveOccupantGeneration: () => "gen-B", // the LIVE occupant at read time
});
// The hook CARRIES gen-A (its emitting occupant), recorded while gen-B is already live.
store.recordHookEvent({
runtime: "codex",
sessionName,
hookEvent: "Stop",
occurredAt: "2026-04-24T11:59:00.000Z",
generation: "gen-A",
});
const latest = store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW });
expect(latest).toMatchObject({
state: "unknown",
reason: "generation_mismatch",
evidenceSource: "runtime_hook",
stale: true,
generationProvenance: "resolved", // both generations WERE resolved; they simply differ
});
});
// (2) CARRIED == LIVE (incl. relaunch-continuation) ⇒ fresh. The hook carries the same generation the
// ledger now reports live (a same-native relaunch is a continuation, no new generation).
it("carried generation equals live (incl. relaunch-continuation) ⇒ fresh, provenance resolved", () => {
const { node, sessionName } = seedGenSession();
const store = new AgentActivityStore({
db,
eventBus,
now: () => NOW,
resolveOccupantGeneration: () => "gen-A",
});
store.recordHookEvent({
runtime: "codex",
sessionName,
hookEvent: "UserPromptSubmit",
occurredAt: "2026-04-24T11:59:30.000Z",
generation: "gen-A",
});
const latest = store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW });
expect(latest).toMatchObject({
state: "running",
reason: "user_prompt_submit",
stale: false,
generationProvenance: "resolved",
});
});
// (3a) UNRESOLVABLE — live side. The live generation is UNKNOWN (resolver ⇒ null) even though the hook
// carried gen-A. ABSENCE of evidence, not a dead-tenure finding: state UNKNOWN + distinct reason +
// stale:true — NEVER fresh — with generationProvenance='unresolved' riding alongside so the row is
// DELIVERED (tap verify-demotion, follow-on). Distinct reason from (3b) and from mismatch.
it("unresolvable live generation (resolver ⇒ null) ⇒ unknown/generation_unresolvable + unresolved, stale", () => {
const { node, sessionName } = seedGenSession();
const store = new AgentActivityStore({
db,
eventBus,
now: () => NOW,
resolveOccupantGeneration: () => null, // the ledger cannot resolve the LIVE occupant generation
});
store.recordHookEvent({
runtime: "codex",
sessionName,
hookEvent: "Stop",
occurredAt: "2026-04-24T11:59:00.000Z",
generation: "gen-A", // carried, but the live side is unknown ⇒ cannot verify
});
const latest = store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW });
expect(latest).toMatchObject({
state: "unknown", // NOT fresh — no false liveness claim
reason: "generation_unresolvable",
stale: true,
generationProvenance: "unresolved",
});
});
// (3b) UNVERIFIABLE — carried side. The hook carried NO generation (the relay/env producer-carry is a
// filed prerequisite, not yet wired ⇒ absent ⇒ null), so it cannot be verified against the live
// generation. Same family as (3a) — unknown + label, never fresh — but a DISTINCT reason (keep them
// separate; ignorance has more than one shape and collapsing them loses forensics). This is the
// INERT-until-producer state: sound, never false-fresh, detection off until the carry lands.
it("unverifiable carried generation (hook carried none) ⇒ unknown/generation_unverifiable + unresolved, stale", () => {
const { node, sessionName } = seedGenSession();
const store = new AgentActivityStore({
db,
eventBus,
now: () => NOW,
resolveOccupantGeneration: () => "gen-B",
});
// Producer did NOT carry a generation ⇒ recorded null (the inert-but-sound path until the carry lands).
store.recordHookEvent({
runtime: "codex",
sessionName,
hookEvent: "Stop",
occurredAt: "2026-04-24T11:59:00.000Z",
});
const latest = store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW });
expect(latest).toMatchObject({
state: "unknown",
reason: "generation_unverifiable",
stale: true,
generationProvenance: "unresolved",
});
});
// (4) ZERO-RIPPLE — with NO resolver injected, reads are unchanged and carry NO provenance label
// (the legacy clock-only path). This is the prod-unreachable branch (production always injects the
// resolver — see the single-construction-site guard), kept green so the injection stays opt-in-safe.
it("no generation resolver ⇒ legacy behavior, no label", () => {
const { node, sessionName } = seedGenSession();
const store = new AgentActivityStore({ db, eventBus, now: () => NOW });
store.recordHookEvent({
runtime: "codex",
sessionName,
hookEvent: "UserPromptSubmit",
occurredAt: "2026-04-24T11:59:30.000Z",
});
const latest = store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW });
expect(latest).toMatchObject({ state: "running", reason: "user_prompt_submit", stale: false });
expect(latest?.generationProvenance).toBeUndefined();
});
// (2b) PURE-FRESHNESS pin — GREEN AT BASE by design (documented so it never reads as a dud). Asserts
// ONLY the same-generation-stays-fresh BEHAVIOUR (which exists at base) and deliberately NOT the new
// provenance label. A red here means freshness regressed, unambiguously — never "the label went
// missing" (test (2) above guards the label). One pin per claim: a pin that can fail for two reasons
// names neither (P32 — assert the discriminating field).
it("same generation ⇒ stays fresh — PURE freshness, no label assertion (green at base + after)", () => {
const { node, sessionName } = seedGenSession();
const store = new AgentActivityStore({
db,
eventBus,
now: () => NOW,
resolveOccupantGeneration: () => "gen-A",
});
store.recordHookEvent({
runtime: "codex",
sessionName,
hookEvent: "UserPromptSubmit",
occurredAt: "2026-04-24T11:59:30.000Z",
generation: "gen-A",
});
const latest = store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW });
expect(latest?.state).toBe("running");
expect(latest?.stale).toBe(false);
// deliberately NO generationProvenance assertion here — freshness-only discriminator.
});
// (5) MECHANISM proof against the SHIPPED occupant-tenure ledger — NOT a constant fake. The READ-side
// resolver is the real currentOccupantTenure(nodeId).generationUuid; the hook CARRIES the emitting
// generation minted through the real mintOccupantTenure. The effect (does the store honor the real
// ledger), not the indicator. Both boundary directions at once: a same-native relaunch is a
// continuation (the live generation stays == the carried one ⇒ fresh); a different-native occupant
// mints a NEW live generation (⇒ the carried prior generation is a dead tenure ⇒ mismatch/refused).
it("REAL ledger: a hook carrying gen-A stays fresh across a relaunch (same native), mismatches a new occupant (different native)", () => {
const { node, sessionName } = seedGenSession();
const store = new AgentActivityStore({
db,
eventBus,
now: () => NOW,
resolveOccupantGeneration: (nodeId) => sessionRegistry.currentOccupantTenure(nodeId)?.generationUuid ?? null,
});
// The emitting occupant's generation, minted through the real ledger, is CARRIED on the hook.
const genA = sessionRegistry.mintOccupantTenure(node.id, "initial", "boot-A").generationUuid;
store.recordHookEvent({
runtime: "codex",
sessionName,
hookEvent: "Stop",
occurredAt: "2026-04-24T11:59:00.000Z",
generation: genA,
});
// RELAUNCH — same native id ⇒ mintOccupantTenure returns the EXISTING tenure (no new generation),
// so the LIVE generation stays gen-A ⇒ carried == live ⇒ fresh.
const genRelaunch = sessionRegistry.mintOccupantTenure(node.id, "initial", "boot-A").generationUuid;
expect(genRelaunch).toBe(genA); // continuation, proven at the ledger (not stipulated by a fake)
expect(store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW })).toMatchObject({
state: "idle",
reason: "stop",
stale: false,
generationProvenance: "resolved",
});
// NEW OCCUPANT — a different native id mints a NEW live generation ⇒ the carried gen-A is a dead tenure.
const genB = sessionRegistry.mintOccupantTenure(node.id, "initial", "boot-B").generationUuid;
expect(genB).not.toBe(genA);
expect(store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW })).toMatchObject({
state: "unknown",
reason: "generation_mismatch",
stale: true,
generationProvenance: "resolved",
});
});
// (6) RESOLVER EXCEPTION ⇒ DEGRADE. The live-generation resolver throws (a transient ledger/db
// fault). The read must NOT crash and must NOT render fresh: a distinct unknown/generation_resolver_
// error verdict, stale, provenance unresolved. Record does not resolve the generation (source-bound),
// so recording never throws either.
it("resolver exception at read ⇒ unknown/generation_resolver_error (degrade), never crashes", () => {
const { node, sessionName } = seedGenSession();
const store = new AgentActivityStore({
db,
eventBus,
now: () => NOW,
resolveOccupantGeneration: () => {
throw new Error("occupant-tenure ledger unavailable");
},
});
expect(() =>
store.recordHookEvent({
runtime: "codex",
sessionName,
hookEvent: "Stop",
occurredAt: "2026-04-24T11:59:00.000Z",
generation: "gen-A",
}),
).not.toThrow();
const latest = store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW });
expect(latest).toMatchObject({
state: "unknown",
reason: "generation_resolver_error",
stale: true,
generationProvenance: "unresolved",
});
});
});
+4
View File
@@ -329,6 +329,10 @@ export function createTestApp(
db,
eventBus,
freshnessMs: opts?.activityFreshnessMs,
// W2a-1 — mirror the production producer wiring so integration tests exercise generation-honesty.
// Fixtures mint occupant tenures at registerSession, so same-tenure reads stay fresh; a test that
// reads across a minted new generation (or with no tenure) sees the honest UNKNOWN.
resolveOccupantGeneration: (nodeId) => sessionRegistry.currentOccupantTenure(nodeId)?.generationUuid ?? null,
});
const podBundleSourceResolver = new PodBundleSourceResolver();
@@ -272,6 +272,11 @@ describe("Slice-04 C4 correction — public-altitude eligibility gate + producer
registryRuntime?: string;
seatRuntime?: string;
now?: string;
// W2a-1 — mint a realistic occupant tenure for the seat (default true). A real registered seat has
// one (minted at the register verbs), so the activity resolves RESOLVED provenance and stays fresh,
// letting the eligibility discriminators (runtime / profile / event-class) actually run. mintTenure:
// false leaves the seat tenure-less ⇒ UNRESOLVED provenance, for the deliberate interim-drop guard.
mintTenure?: boolean;
}
function publicFixture(options: PublicFixtureOptions = {}) {
@@ -296,6 +301,13 @@ describe("Slice-04 C4 correction — public-altitude eligibility gate + producer
).run("node-1", "rig-1", "dev.impl", options.seatRuntime ?? "codex", "/project", "pod-1", "local:agents/impl", "default", "impl", "1.0.0", "abc123");
db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status) VALUES (?, ?, ?, ?, ?)").run("session-1", "node-1", SESSION, "running", "ready");
db.prepare("INSERT INTO bindings (id, node_id, tmux_session) VALUES (?, ?, ?)").run("binding-1", "node-1", SESSION);
if (options.mintTenure !== false) {
// A real registered seat mints an occupant tenure at the register verbs — reproduce it so the
// read resolves RESOLVED provenance (fresh), not the tenure-less UNRESOLVED path.
db.prepare(
"INSERT INTO occupant_tenures (id, node_id, generation_ordinal, generation_uuid, kind, native_session_id_at_boot) VALUES (?, ?, ?, ?, ?, ?)",
).run("tenure-node-1", "node-1", 1, "gen-uuid-node-1", "initial", null);
}
const testApp = createTestApp(db, { activityHookToken: TOKEN });
const service = new ProviderServiceImpl({
@@ -310,7 +322,7 @@ describe("Slice-04 C4 correction — public-altitude eligibility gate + producer
async function postHook(
app: ReturnType<typeof createTestApp>["app"],
input: { runtime?: string; hookEvent?: string; subtype?: string; occurredAt?: string },
input: { runtime?: string; hookEvent?: string; subtype?: string; occurredAt?: string; generation?: string | null },
): Promise<Response> {
return app.request("/api/activity/hooks", {
method: "POST",
@@ -321,6 +333,10 @@ describe("Slice-04 C4 correction — public-altitude eligibility gate + producer
hookEvent: input.hookEvent ?? "Notification",
subtype: input.subtype,
occurredAt: input.occurredAt ?? EVENT_AT,
// W2a-1 — the emitting occupant's generation, carried source-bound (simulate the WIRED producer).
// Defaults to the fixture's minted tenure generation so the read RESOLVES (carried == live) and
// the eligibility discriminators actually run; pass null to exercise the INERT unwired path.
generation: input.generation === undefined ? "gen-uuid-node-1" : input.generation,
}),
});
}
@@ -343,6 +359,19 @@ describe("Slice-04 C4 correction — public-altitude eligibility gate + producer
}]);
});
// W2a-1 CONSUMER-LEVEL guard — the INERT-until-producer state. The seat HAS a tenure (the read-side
// resolver returns a live generation), but the relay/env producer-carry is NOT yet wired, so the hook
// carries NO generation ⇒ recorded null ⇒ generation_unverifiable ⇒ unknown/stale on the ACTIVITY
// (asserted in agent-activity-store.test.ts). This fold ships the STORE half + route ingestion only;
// the source-bound producer-carry (relay/env) is a filed PREREQUISITE and the tap's verify-demotion
// is a filed FOLLOW-ON. So the unchanged tap discards the unverifiable read → no reactive row yet.
// Sound (never false-fresh), inert until the carry lands; pins it honestly, NOT a restored false-fresh.
it("W2a-1 (interim/inert): producer not carrying the generation (hook carries none) ⇒ unverifiable ⇒ store-delivered unknown, tap does not yet emit", async () => {
const fx = publicFixture(); // seat HAS a tenure; the relay/env generation-carry is unwired
expect((await postHook(fx.app, { runtime: "codex", hookEvent: "rate_limit", generation: null })).status).toBe(200);
expect(reactiveRows(await fx.service.getReadModel())).toEqual([]);
});
it("HIGH-1a: a claude-code activity attached to a Codex seat must NOT emit a Codex row", async () => {
const fx = publicFixture();
expect((await postHook(fx.app, { runtime: "claude-code", hookEvent: "rate_limit" })).status).toBe(200);
@@ -407,6 +407,48 @@ describe("OPR.0.4.1.10 rig send prompt/permission guard (keystone)", () => {
expect(sendText).toHaveBeenCalled();
});
// W2a-1 round-3 — the producer-link advisory must NOT collapse a FRESH generation verdict into a
// false clock-stale/seat-quiet warning. A resolver-backed store + a hook that carried NO generation
// ⇒ generation_unverifiable (state unknown, stale:true, but age ~0). The advisory must name the
// generation cause honestly (UNVERIFIABLE — either not-wired OR no-tenure-at-fire), never "beyond the
// store window / seat quiet". This is the inert-visible differentiation at the SessionTransport seam.
it("W2a-1: a FRESH generation_unverifiable read yields a generation-distinct advisory, NOT clock-stale/seat-quiet", async () => {
const now = new Date("2026-06-27T12:00:00.000Z");
// The seat HAS a tenure (registerSession minted one), so the live generation resolves; the hook
// carries NO generation ⇒ generation_unverifiable (fresh, age ~0).
const genStore = new AgentActivityStore({
db,
eventBus,
now: () => now,
resolveOccupantGeneration: (nodeId) => sessionRegistry.currentOccupantTenure(nodeId)?.generationUuid ?? null,
});
genStore.recordHookEvent({
runtime: "claude-code",
sessionName: "dev-impl@my-rig",
hookEvent: "UserPromptSubmit",
occurredAt: now.toISOString(),
// deliberately NO generation carried ⇒ recorded null ⇒ unverifiable
});
const { sendText } = spies();
const t = new SessionTransport({
db,
rigRepo,
sessionRegistry,
eventBus,
tmuxAdapter: mockTmux({ capturePaneContent: async () => "xyzzy no prompt here", sendText }),
agentActivityStore: genStore,
now: () => now,
});
const r = await t.send("dev-impl@my-rig", "hi");
expect(r.ok).toBe(true);
expect(r.warning).toContain("producer-link:");
expect(r.warning).toContain("carried NO occupant generation"); // generation-distinct, not clock-stale
expect(r.warning).toContain("UNVERIFIABLE, not a quiet seat");
expect(r.warning).not.toContain("beyond the store window");
expect(r.warning).not.toContain("gone quiet");
expect(sendText).toHaveBeenCalled();
});
it("AMEND: a hook fresh within the send window (idle) is authoritative and the send proceeds", async () => {
const fixedNow = new Date("2026-06-27T12:00:00.000Z");
agentActivityStore.recordHookEvent({
@@ -643,6 +643,9 @@ describe("Session routes", () => {
hookEvent: "Notification",
subtype: "permission_prompt",
occurredAt: new Date().toISOString(),
// W2a-1 — carry the emitting occupant generation (source-bound) so the read RESOLVES and the fresh
// hook state is honored, rather than degrading to generation_unverifiable.
generation: sessionRegistry.currentOccupantTenure(node.id)?.generationUuid ?? null,
});
const res = await app.request(`/api/rigs/${rig.id}/nodes`);
@@ -680,6 +683,9 @@ describe("Session routes", () => {
sessionName: "dev-impl@test-rig",
hookEvent: "UserPromptSubmit",
occurredAt: "2000-01-01T00:00:00.000Z",
// W2a-1 — carry the emitting generation so the read RESOLVES, letting the CLOCK-staleness verdict
// (stale_runtime_hook) fire — rather than the generation gate short-circuiting to unverifiable.
generation: sessionRegistry.currentOccupantTenure(node.id)?.generationUuid ?? null,
});
const res = await app.request(`/api/rigs/${rig.id}/nodes`);