@afokapu/atdd-flow 0.3.54 → 0.3.56

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.54",
3
+ "version": "0.3.56",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,234 @@
1
+ // URN: component:flow:e006:exact-adoption-recovery:backend:integration
2
+ // Tested-By:
3
+ // - test:flow:exact-adoption-resumed-proof-and-relaunch
4
+ // Runtime: bun
5
+ // Purpose: Classify the immutable evidence for one exact Path-A adoption and recovery.
6
+
7
+ import { createHash } from "node:crypto";
8
+ import { readFile, readdir } from "node:fs/promises";
9
+ import { basename, join, resolve } from "node:path";
10
+ import { type Seat, paths, readYaml, yaml } from "./core";
11
+ import type { HandoverReplacementRequest } from "./handover";
12
+ import { readRuntimeState } from "./runtime-state";
13
+ import type { Task } from "./tasks";
14
+
15
+ type ReplacementLaunchReceipt = {
16
+ schema?: string;
17
+ seat?: string;
18
+ tasks?: string[];
19
+ pi_session?: string;
20
+ pi_session_path?: string;
21
+ herdr_session?: string;
22
+ pane?: string;
23
+ handover_replacement?: { request?: HandoverReplacementRequest; successor_session?: string };
24
+ relocation?: {
25
+ source_herdr_session?: string;
26
+ source_pane?: string;
27
+ authorization?: { id?: string; hash?: string };
28
+ backup?: string;
29
+ backup_sha256?: string;
30
+ };
31
+ created_at?: string;
32
+ };
33
+
34
+ type CanonicalSource = NonNullable<NonNullable<HandoverReplacementRequest["supersession"]>["canonical_source"]>;
35
+ type TaskEligibility = (task: Task) => boolean | Promise<boolean>;
36
+
37
+ /** Hash bytes exactly as the immutable records were published. */
38
+ function digest(content: string | Uint8Array) {
39
+ return createHash("sha256").update(content).digest("hex");
40
+ }
41
+
42
+ /** Match both the authorization content and its separately stored digest. */
43
+ async function exactAuthorizationMatches(root: string, request: HandoverReplacementRequest) {
44
+ const id = request.authorization.id;
45
+ const folder = join(root, ".atdd-flow", "exact-session-adoption-authorizations");
46
+ try {
47
+ const raw = await readFile(join(folder, `${id}.yaml`), "utf8");
48
+ const storedHash = (await readFile(join(folder, `${id}.sha256`), "utf8")).trim();
49
+ const record = object(yaml.parse<unknown>(raw));
50
+ const source = object(record?.source), target = object(record?.target);
51
+ const observed = {
52
+ storedHash, contentHash: digest(raw), schema: record?.schema, id: record?.id, issuedBy: record?.issued_by,
53
+ seat: record?.seat, session: record?.pi_session, sessionPath: record?.pi_session_path,
54
+ sourceSession: source?.herdr_session, sourcePane: source?.pane,
55
+ targetSession: target?.herdr_session, targetPane: target?.pane, targetCwd: target?.cwd,
56
+ };
57
+ const expected = {
58
+ storedHash: request.authorization.hash, contentHash: request.authorization.hash,
59
+ schema: "atdd-flow/exact-session-adoption-authorization/v1", id, issuedBy: "operator@desk",
60
+ seat: request.seat, session: request.source.pi_session, sessionPath: request.source.pi_session_path,
61
+ sourceSession: request.source.herdr_session, sourcePane: request.source.pane,
62
+ targetSession: request.target.herdr_session, targetPane: request.target.pane, targetCwd: request.target.cwd,
63
+ };
64
+ return JSON.stringify(observed) === JSON.stringify(expected);
65
+ } catch { return false; }
66
+ }
67
+
68
+ function object(value: unknown): Record<string, unknown> | undefined {
69
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
70
+ }
71
+
72
+ /** Validate the prior canonical receipt without trusting the active adopted receipt. */
73
+ function receiptMatchesCanonical(receipt: ReplacementLaunchReceipt, seat: string, canonical: CanonicalSource) {
74
+ return receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1" && receipt.seat === seat
75
+ && receipt.pi_session === canonical.pi_session && receipt.pi_session_path === canonical.pi_session_path
76
+ && receipt.herdr_session === canonical.herdr_session && receipt.pane === canonical.pane;
77
+ }
78
+
79
+ /** Bind the archived prior receipt to its archived request and successor. */
80
+ function receiptProvesRequest(receipt: ReplacementLaunchReceipt, request: HandoverReplacementRequest, canonical: CanonicalSource) {
81
+ return Array.isArray(receipt.tasks) && receipt.tasks.includes(request.task)
82
+ && receipt.handover_replacement?.successor_session === canonical.pi_session
83
+ && JSON.stringify(receipt.handover_replacement.request) === JSON.stringify(request);
84
+ }
85
+
86
+ function validConsumedRequest(request: HandoverReplacementRequest, seat: string) {
87
+ return request.schema === "atdd-flow/context-handover-replacement-request/v1" && request.seat === seat
88
+ && Boolean(request.task && request.handover?.id && request.handover.sha256);
89
+ }
90
+
91
+ /** Rebuild and compare every archived supersession byte and identity. */
92
+ async function exactArchiveMatches(request: HandoverReplacementRequest) {
93
+ const proof = request.supersession;
94
+ if (!proof) return false;
95
+ try {
96
+ const requestPath = join(proof.prior_request.archive, "request.yaml");
97
+ const receiptPath = join(proof.prior_request.archive, "launch-receipt.yaml");
98
+ const manifestPath = join(proof.prior_request.archive, "supersession.yaml");
99
+ const [files, requestBytes, receiptBytes, manifestBytes] = await Promise.all([
100
+ readdir(proof.prior_request.archive), readFile(requestPath, "utf8"),
101
+ readFile(receiptPath, "utf8"), readFile(manifestPath, "utf8"),
102
+ ]);
103
+ const archivedRequest = yaml.parse<HandoverReplacementRequest>(requestBytes);
104
+ const archivedReceipt = yaml.parse<ReplacementLaunchReceipt>(receiptBytes);
105
+ const manifest = yaml.print({
106
+ schema: "atdd-flow/context-handover-replacement-supersession/v1", seat: request.seat,
107
+ prior_request: {
108
+ path: proof.prior_request.path, sha256: proof.prior_request.sha256, task: proof.prior_request.task,
109
+ handover: proof.prior_request.handover, handover_sha256: proof.prior_request.handover_sha256,
110
+ },
111
+ prior_receipt: proof.prior_receipt,
112
+ });
113
+ return [
114
+ files.sort().join("\n") === ["launch-receipt.yaml", "request.yaml", "supersession.yaml"].join("\n"),
115
+ basename(proof.prior_request.archive) === proof.prior_request.sha256,
116
+ digest(requestBytes) === proof.prior_request.sha256,
117
+ digest(receiptBytes) === proof.prior_receipt.sha256,
118
+ manifestBytes === manifest,
119
+ validConsumedRequest(archivedRequest, request.seat),
120
+ archivedRequest.task === proof.prior_request.task,
121
+ archivedRequest.handover.id === proof.prior_request.handover,
122
+ archivedRequest.handover.sha256 === proof.prior_request.handover_sha256,
123
+ receiptMatchesCanonical(archivedReceipt, request.seat, proof.canonical_source),
124
+ receiptProvesRequest(archivedReceipt, archivedRequest, proof.canonical_source),
125
+ ].every(Boolean);
126
+ } catch { return false; }
127
+ }
128
+
129
+ /** A single authorization may identify exactly one adoption receipt. */
130
+ async function uniqueAdoptionReceipt(root: string, authorizationId: string, expectedPath: string) {
131
+ try {
132
+ const folder = join(root, ".atdd-flow", "runtime-launch");
133
+ const candidates: string[] = [];
134
+ for (const file of await readdir(folder)) {
135
+ if (!file.endsWith(".yaml")) continue;
136
+ const path = join(folder, file);
137
+ try {
138
+ const receipt = yaml.parse<ReplacementLaunchReceipt>(await readFile(path, "utf8"));
139
+ if (receipt.relocation?.authorization?.id === authorizationId) candidates.push(path);
140
+ } catch { /* Malformed receipts cannot prove exact adoption. */ }
141
+ }
142
+ return candidates.length === 1 && resolve(candidates[0]!) === resolve(expectedPath);
143
+ } catch { return false; }
144
+ }
145
+
146
+ /** The failed adopted runtime retains source identity at the exact target pane. */
147
+ function exactAdoptionRuntimeMatches(record: Seat, request: HandoverReplacementRequest) {
148
+ const runtime = record.runtime, locator = runtime?.addresses.herdr;
149
+ const observed = {
150
+ supersession: Boolean(request.supersession), application: runtime?.application, wake: runtime?.wake,
151
+ session: runtime?.pi_session, sessionPath: runtime?.pi_session_path,
152
+ herdrSession: typeof locator === "string" ? undefined : locator?.session,
153
+ pane: typeof locator === "string" ? undefined : locator?.pane,
154
+ };
155
+ const expected = {
156
+ supersession: true, application: "herdr", wake: "native",
157
+ session: request.source.pi_session, sessionPath: request.source.pi_session_path,
158
+ herdrSession: request.target.herdr_session, pane: request.target.pane,
159
+ };
160
+ return JSON.stringify(observed) === JSON.stringify(expected);
161
+ }
162
+
163
+ /** Require the active request and receipt to describe one exact relocation. */
164
+ function exactAdoptionReceiptMatches(receipt: ReplacementLaunchReceipt, activeBytes: string, address: string, request: HandoverReplacementRequest) {
165
+ const relocation = receipt.relocation;
166
+ return [
167
+ activeBytes === yaml.print(request),
168
+ yaml.print(receipt.handover_replacement?.request) === activeBytes,
169
+ receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1",
170
+ receipt.seat === address,
171
+ receipt.tasks?.length === 1,
172
+ receipt.tasks?.[0] === request.task,
173
+ receipt.pi_session === request.source.pi_session,
174
+ receipt.pi_session_path === request.source.pi_session_path,
175
+ receipt.herdr_session === request.target.herdr_session,
176
+ receipt.pane === request.target.pane,
177
+ receipt.handover_replacement?.successor_session === request.source.pi_session,
178
+ relocation?.source_herdr_session === request.source.herdr_session,
179
+ relocation?.source_pane === request.source.pane,
180
+ relocation?.authorization?.id === request.authorization.id,
181
+ relocation?.authorization?.hash === request.authorization.hash,
182
+ Boolean(relocation?.backup), Boolean(relocation?.backup_sha256), Boolean(receipt.created_at),
183
+ ].every(Boolean);
184
+ }
185
+
186
+ /** Prove the immutable recovery copy is the prefix of the adopted source JSONL. */
187
+ async function exactBackupMatches(receipt: ReplacementLaunchReceipt, request: HandoverReplacementRequest) {
188
+ const relocation = receipt.relocation!;
189
+ const [backup, source] = await Promise.all([readFile(relocation.backup!), readFile(request.source.pi_session_path)]);
190
+ return digest(backup) === relocation.backup_sha256
191
+ && source.length >= backup.length
192
+ && source.subarray(0, backup.length).equals(backup);
193
+ }
194
+
195
+ /** A live same-identity adoption is fresh only after its receipt and start. */
196
+ function freshAdoptionHeartbeat(runtimeState: Awaited<ReturnType<typeof readRuntimeState>>, receipt: ReplacementLaunchReceipt, address: string, cwd: string) {
197
+ if (!runtimeState) return false;
198
+ return [
199
+ runtimeState.seat === address,
200
+ resolve(runtimeState.cwd) === resolve(cwd),
201
+ Date.parse(runtimeState.started_at) >= Date.parse(receipt.created_at!),
202
+ Date.parse(runtimeState.heartbeat_at) >= Date.parse(runtimeState.started_at),
203
+ ].every(Boolean);
204
+ }
205
+
206
+ /**
207
+ * Classify only the complete immutable evidence emitted by one exact Path-A
208
+ * adoption. The caller supplies the narrow task lifecycle accepted at its
209
+ * boundary; every receipt, archive, authorization, backup, and runtime check
210
+ * remains shared by adopted RESUMED proof and failed-adoption relaunch.
211
+ * Missing or contradictory evidence always returns no classification.
212
+ */
213
+ export async function exactFailedAdoptionProof(root: string, record: Seat, address: string, request: HandoverReplacementRequest, taskEligible: TaskEligibility) {
214
+ const receiptPath = record.runtime?.launch_receipt;
215
+ if (!receiptPath || !exactAdoptionRuntimeMatches(record, request)) return undefined;
216
+ try {
217
+ const activePath = join(root, ".atdd-flow", "handover-replacements", `${encodeURIComponent(address)}.yaml`);
218
+ const [activeBytes, receiptBytes, runtimeState, task] = await Promise.all([
219
+ readFile(activePath, "utf8"), readFile(receiptPath, "utf8"), readRuntimeState(root, address),
220
+ readYaml<Task>(paths(root).taskFile(record.project, request.task)),
221
+ ]);
222
+ const receipt = yaml.parse<ReplacementLaunchReceipt>(receiptBytes);
223
+ const evidence = [
224
+ exactAdoptionReceiptMatches(receipt, activeBytes, address, request),
225
+ await taskEligible(task),
226
+ await exactAuthorizationMatches(root, request),
227
+ await exactArchiveMatches(request),
228
+ await uniqueAdoptionReceipt(root, request.authorization.id, receiptPath),
229
+ await exactBackupMatches(receipt, request),
230
+ ];
231
+ if (!evidence.every(Boolean)) return undefined;
232
+ return { receipt, runtimeState, heartbeat: freshAdoptionHeartbeat(runtimeState, receipt, address, request.target.cwd) };
233
+ } catch { return undefined; }
234
+ }
package/src/handover.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // URN: component:flow:e005:handover:backend:integration
2
2
  // Tested-By:
3
3
  // - test:atdd-flow:repeat-handover-replacement-supersession
4
+ // - test:flow:dos-supersession-launch-eligibility
4
5
  // Runtime: bun
5
6
  // Purpose: Validate, preserve, publish, and reconcile governed context handover evidence.
6
7
 
@@ -10,6 +11,7 @@ import { basename, dirname, join, resolve } from "node:path";
10
11
  import { type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, has, now, paths, project, readYaml, required, runOutput, seat, words, yaml } from "./core";
11
12
  import { type Task, terminal } from "./tasks";
12
13
  import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
14
+ import { exactFailedAdoptionProof } from "./exact-adoption-recovery";
13
15
  import { readRuntimeState } from "./runtime-state";
14
16
 
15
17
  export type Handover = {
@@ -211,13 +213,31 @@ export async function mainLaunchEligible(root: string, record: Seat, address: st
211
213
  && await isExactMainSeat(root, record, address);
212
214
  }
213
215
 
216
+ function evidenceEmptyRecoveryTask(task: Task) {
217
+ return task.status === "in_progress" && !task.blocker && !task.handoff
218
+ && task.done_when.every((criterion) => !criterion.proof);
219
+ }
220
+
221
+ async function failedAdoptionTaskEligible(root: string, record: Seat, address: string, task: Task) {
222
+ return (task.status === "todo" || evidenceEmptyRecoveryTask(task))
223
+ && task.coordinator === address && task.assignee === address
224
+ && await isExactMainSeat(root, record, address);
225
+ }
226
+
214
227
  export async function replacementForMainLaunch(root: string, record: Seat, address: string) {
215
228
  if (!await isExactMainSeat(root, record, address)) return undefined;
216
229
  const request = await readHandoverReplacement(root, address).catch(() => {
217
230
  throw new Error(`${address} requires one validated Path-A replacement request; refusing runtime launch.`);
218
231
  });
219
232
  const task = await readYaml<Task>(paths(root).taskFile(record.project, request.task));
220
- if (!replacementMatchesRuntime(record.runtime, request) || !await mainLaunchEligible(root, record, address, task)) {
233
+ const failedAdoption = await exactFailedAdoptionProof(root, record, address, request,
234
+ (task) => failedAdoptionTaskEligible(root, record, address, task));
235
+ const canonicalLaunch = replacementMatchesRuntime(record.runtime, request)
236
+ && await (request.supersession
237
+ ? failedAdoptionTaskEligible(root, record, address, task)
238
+ : mainLaunchEligible(root, record, address, task));
239
+ const failedAdoptionRecovery = Boolean(failedAdoption && !failedAdoption.runtimeState);
240
+ if (!canonicalLaunch && !failedAdoptionRecovery) {
221
241
  throw new Error("Path-A replacement source tuple no longer matches the recorded runtime or ready main takeover task.");
222
242
  }
223
243
  return { request, task };
@@ -739,10 +759,12 @@ export async function resumedHandoverReplacement(root: string, address: string)
739
759
  const receipt = runtime?.launch_receipt && await exists(runtime.launch_receipt) ? await readYaml<Record<string, unknown>>(runtime.launch_receipt) : undefined;
740
760
  const replacement = receipt?.handover_replacement as Record<string, unknown> | undefined;
741
761
  const runtimeState = await readRuntimeState(root, resolved);
762
+ const failedAdoption = await exactFailedAdoptionProof(root, record, resolved, request,
763
+ (task) => failedAdoptionTaskEligible(root, record, resolved, task));
742
764
  const verified = [
743
765
  request.schema === "atdd-flow/context-handover-replacement-request/v1" && request.seat === resolved,
744
766
  runtime?.application === "herdr" && runtime.wake === "native",
745
- runtime?.pi_session !== request.source.pi_session && Boolean(runtime?.pi_session_path),
767
+ runtime?.pi_session !== request.source.pi_session && Boolean(runtime?.pi_session_path) || Boolean(failedAdoption?.heartbeat),
746
768
  typeof locator !== "string" && locator?.session === request.target.herdr_session && locator?.pane === request.target.pane,
747
769
  replacement?.successor_session === runtime?.pi_session && replacement?.request && JSON.stringify(replacement.request) === JSON.stringify(request),
748
770
  Boolean(runtimeState && runtimeState.seat === resolved),