@afokapu/atdd-flow 0.3.72 → 0.3.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.72",
3
+ "version": "0.3.73",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,7 @@
2
2
  // Tested-By:
3
3
  // - test:atdd-flow:stale-main-binding-recovery
4
4
  // - test:atdd-flow:stale-main-recovery-resumed
5
+ // - test:atdd-flow:stale-main-source-exited-path-a
5
6
  // Runtime: bun
6
7
  // Purpose: Evidence rules for operator-authorized stale-main Pi binding recovery.
7
8
 
@@ -20,10 +21,13 @@ export type StaleMainRecovery = {
20
21
  canonical_source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; cwd: string; launch_receipt: string };
21
22
  prior_receipt: { path: string; sha256: string; archive: string };
22
23
  /** A superseded active request that no longer matches the recorded runtime, preserved by hash. */
23
- prior_request?: { path: string; sha256: string; archive: string };
24
+ prior_request?: { path: string; sha256: string; archive: string; source_exited?: SourceExited };
24
25
  live_source: LiveSourceProof;
25
26
  };
26
27
 
28
+ /** Proof that a runtime-matching prior request's recorded source Pi was absent from every live Pi agent when it was replaced. */
29
+ export type SourceExited = { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; verified_at: string };
30
+
27
31
  type Source = HandoverReplacementRequest["source"];
28
32
  type Target = HandoverReplacementRequest["target"];
29
33
  type PriorReceipt = { schema?: string; seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
@@ -86,17 +90,28 @@ async function archiveBytes(root: string, folder: string, seat: string, bytes: s
86
90
  }
87
91
 
88
92
  /**
89
- * An existing active request may be replaced only when it no longer matches the recorded runtime;
90
- * a request that still matches is live and must go through the ordinary flow. The old bytes are archived by hash.
93
+ * An existing active request may be replaced when it no longer matches the recorded runtime. A request that still
94
+ * matches is replaceable only when its recorded source is exactly the stale canonical source, which the caller has
95
+ * already proven absent from every live Pi agent. The old bytes are archived by hash with that source-exit evidence.
91
96
  */
92
- async function archivePriorRequest(root: string, seat: string, file: string, isLive: (request: HandoverReplacementRequest) => boolean) {
97
+ async function archivePriorRequest(root: string, seat: string, file: string, isLive: (request: HandoverReplacementRequest) => boolean, exit: { canonical: StaleMainRecovery["canonical_source"]; verifiedAt: string }) {
93
98
  let bytes: string;
94
99
  try { bytes = await readFile(file, "utf8"); } catch { return undefined; }
95
100
  const prior = yaml.parse<HandoverReplacementRequest>(bytes);
96
- if (prior.seat !== seat || isLive(prior)) throw new Error("A live Path-A replacement request already exists; stale main recovery only replaces a request that no longer matches the recorded runtime.");
97
- return { path: file, ...await archiveBytes(root, "stale-main-request-archive", seat, bytes) };
101
+ const matching = isLive(prior), exited = matching && sourceIsCanonical(prior.source, exit.canonical);
102
+ if (prior.seat !== seat || (matching && !exited)) throw new Error("A live Path-A replacement request already exists; stale main recovery only replaces a request that no longer matches the recorded runtime or whose matching source has exited.");
103
+ const source_exited = exited ? sourceExited(exit.canonical, exit.verifiedAt) : undefined;
104
+ return { path: file, ...await archiveBytes(root, "stale-main-request-archive", seat, bytes), ...(source_exited ? { source_exited } : {}) };
105
+ }
106
+
107
+ function sourceIsCanonical(source: Source, canonical: StaleMainRecovery["canonical_source"]) {
108
+ return source.pi_session === canonical.pi_session && source.pi_session_path === canonical.pi_session_path
109
+ && source.herdr_session === canonical.herdr_session && source.pane === canonical.pane;
98
110
  }
99
111
 
112
+ const sourceExited = (canonical: StaleMainRecovery["canonical_source"], verified_at: string): SourceExited =>
113
+ ({ pi_session: canonical.pi_session, pi_session_path: canonical.pi_session_path, herdr_session: canonical.herdr_session, pane: canonical.pane, verified_at });
114
+
100
115
  /** Swap the active request through a staged file so readers never observe a partial request. */
101
116
  export async function replaceActiveRequest(file: string, content: string) {
102
117
  const staged = `${file}.${crypto.randomUUID()}.tmp`;
@@ -141,13 +156,18 @@ function snapshotMatches(request: HandoverReplacementRequest, live: LiveSourcePr
141
156
  && live.pane === request.source.pane && live.cwd === request.target.cwd && Boolean(live.agent && live.verified_at);
142
157
  }
143
158
 
159
+ function exitMatches(recovery: StaleMainRecovery) {
160
+ const exited = recovery.prior_request?.source_exited;
161
+ return !exited || (sourceIsCanonical({ ...exited }, recovery.canonical_source) && Boolean(exited.verified_at));
162
+ }
163
+
144
164
  function archiveNamed(prior: { sha256: string; archive: string } | undefined) {
145
165
  return !prior || (/^[a-f0-9]{64}$/i.test(prior.sha256) && basename(prior.archive) === `${prior.sha256}.yaml`);
146
166
  }
147
167
 
148
168
  /** Request, canonical source and live-source snapshot must describe one exact target tuple. */
149
169
  export function recoveryEvidenceMatches(request: HandoverReplacementRequest, recovery: StaleMainRecovery) {
150
- return targetMatches(request, recovery.canonical_source) && snapshotMatches(request, recovery.live_source) && archiveNamed(recovery.prior_receipt) && archiveNamed(recovery.prior_request);
170
+ return targetMatches(request, recovery.canonical_source) && snapshotMatches(request, recovery.live_source) && archiveNamed(recovery.prior_receipt) && archiveNamed(recovery.prior_request) && exitMatches(recovery);
151
171
  }
152
172
 
153
173
  /** The recorded runtime must still be the exact stale canonical source named by the recovery request. */
@@ -159,6 +179,6 @@ export function staleRecoveryMatches(runtime: Runtime | undefined, request: Hand
159
179
  export async function buildStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, active: { file: string; isLive: (request: HandoverReplacementRequest) => boolean }, verify: (canonical: StaleMainRecovery["canonical_source"]) => Promise<LiveSourceProof>): Promise<StaleMainRecovery> {
160
180
  const canonical = staleCanonicalSource(intent.record, intent.source, intent.target);
161
181
  const live_source = await verify(canonical);
162
- const prior_request = await archivePriorRequest(root, seat, active.file, active.isLive);
182
+ const prior_request = await archivePriorRequest(root, seat, active.file, active.isLive, { canonical, verifiedAt: live_source.verified_at });
163
183
  return { canonical_source: canonical, prior_receipt: await archiveStaleReceipt(root, seat, canonical), ...(prior_request ? { prior_request } : {}), live_source };
164
184
  }