@junghanacs/entwurf 0.12.8 → 0.12.10
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/AGENTS.md +134 -248
- package/BASELINE.md +1 -1
- package/CHANGELOG.md +39 -1
- package/DELIVERY.md +5 -4
- package/README.md +148 -23
- package/VERIFY.md +3 -2
- package/demo/README.md +2 -2
- package/demo/demo.sh +7 -1
- package/docs/setup-clean-host.md +24 -7
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +168 -81
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +12 -12
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +5 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +16 -18
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +14 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +11 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +15 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +500 -54
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +8 -3
- package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +14 -2
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +155 -29
- package/mcp/entwurf-bridge/src/index.ts +200 -94
- package/package.json +11 -9
- package/pi-extensions/entwurf-control.ts +81 -39
- package/pi-extensions/lib/acp/models.ts +12 -12
- package/pi-extensions/lib/acp/overlay.ts +5 -3
- package/pi-extensions/lib/entwurf-core.ts +26 -9
- package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
- package/pi-extensions/lib/entwurf-fact-provider.ts +24 -19
- package/pi-extensions/lib/entwurf-facts.ts +13 -13
- package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
- package/pi-extensions/lib/entwurf-self-address.ts +8 -9
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
- package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
- package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
- package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
- package/pi-extensions/lib/entwurf-v2-production.ts +22 -13
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +11 -3
- package/pi-extensions/lib/meta-sender-identity.ts +15 -5
- package/pi-extensions/lib/meta-session.ts +543 -58
- package/pi-extensions/meta-bridge-hook.ts +8 -2
- package/run.sh +78 -67
- package/scripts/agy-bridge-config.py +249 -49
- package/scripts/agy-bridge.sh +59 -14
- package/scripts/agy-imprint.ts +15 -1
- package/scripts/check-acp-carrier-augment.ts +34 -2
- package/scripts/check-acp-sdk-surface.ts +22 -11
- package/scripts/check-acp-session-reuse.ts +1 -1
- package/scripts/check-acp-session-store.ts +3 -3
- package/scripts/check-agy-permission-matrix.py +655 -0
- package/scripts/check-agy-sender-identity.ts +83 -0
- package/scripts/check-entwurf-control-rpc.ts +2 -2
- package/scripts/check-entwurf-deliverability.ts +9 -6
- package/scripts/check-entwurf-fact-provider.ts +16 -8
- package/scripts/check-entwurf-facts.ts +13 -13
- package/scripts/check-entwurf-resume-args.ts +25 -63
- package/scripts/check-entwurf-self-address.ts +187 -4
- package/scripts/check-entwurf-session-identity.ts +7 -6
- package/scripts/check-entwurf-v2-contract.ts +2 -2
- package/scripts/check-entwurf-v2-production.ts +9 -7
- package/scripts/check-entwurf-v2-spawn-production.ts +8 -4
- package/scripts/check-entwurf-v2-surface.ts +327 -14
- package/scripts/check-fresh-cut-gate.sh +305 -4
- package/scripts/check-gate-qualification.ts +785 -0
- package/scripts/check-meta-identity-consumers.ts +501 -1
- package/scripts/check-meta-listing.ts +91 -9
- package/scripts/check-meta-receiver-marker.ts +54 -0
- package/scripts/check-model-lock.ts +1 -1
- package/scripts/check-shell-quote.ts +2 -1
- package/scripts/lib/mutation-qualify.ts +794 -0
- package/scripts/meta-bridge-fresh-cut.ts +164 -28
- package/scripts/mutants/acp-augment.json +30 -0
- package/scripts/mutants/agy-permission.json +144 -0
- package/scripts/mutants/meta-identity.json +17 -0
- package/scripts/mutants/self-address.json +59 -0
- package/scripts/mutants/v2-surface.json +87 -0
- package/scripts/pi_settings_io.py +65 -0
- package/scripts/register-pi-package.py +183 -37
- package/scripts/register-pi-provider.py +68 -10
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-acp-v2-send-live.ts +33 -18
- package/scripts/smoke-agy-install-state.sh +205 -20
- package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
- package/scripts/smoke-meta-install-state.sh +1 -1
- package/scripts/smoke-pi-attach.ts +7 -2
- package/scripts/smoke-user-scope-citizen.sh +177 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
- package/scripts/check-entwurf-mailbox-guard.ts +0 -262
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* meta-session —
|
|
2
|
+
* meta-session — the shared V3 garden-citizen RECORD AUTHORITY.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* This module entered through the native meta-bridge (#30), then became the one
|
|
5
|
+
* identity store for every addressable citizen, including `backend:"pi"` (#50).
|
|
6
|
+
* A record is a backend-owned-session bib card: it binds the backend's native id
|
|
7
|
+
* and transcript pointer to a garden id without taking over that backend's runtime,
|
|
8
|
+
* auth, or transcript. The module name is history; the live identity schema is not
|
|
9
|
+
* a native-only or pi-exception axis.
|
|
10
10
|
*
|
|
11
11
|
* Two layers, clearly sectioned:
|
|
12
12
|
* 1. RECORD functions + types (mint / serialize / parse / certifyActiveStore /
|
|
@@ -61,7 +61,7 @@ import { generateSessionId, SESSION_ID_RE } from "./session-id.js";
|
|
|
61
61
|
// Errors
|
|
62
62
|
// ---------------------------------------------------------------------------
|
|
63
63
|
|
|
64
|
-
/** A
|
|
64
|
+
/** A garden-citizen record is malformed, or an input violates the record contract. */
|
|
65
65
|
export class MetaRecordError extends Error {
|
|
66
66
|
constructor(message: string) {
|
|
67
67
|
super(message);
|
|
@@ -142,8 +142,9 @@ export function requireNonEmptyString(value: unknown, field: string): string {
|
|
|
142
142
|
|
|
143
143
|
/** Validate the 3-backend NATIVE bridge axis (sender/receiver markers, capability
|
|
144
144
|
* drift guard). Not a record-schema validator: identity records take
|
|
145
|
-
* `requireCitizenBackend` (which admits `pi`)
|
|
146
|
-
*
|
|
145
|
+
* `requireCitizenBackend` (which admits `pi`). Markers stay native-3 because the
|
|
146
|
+
* pi adapter carries its record-established garden id into children via env rather
|
|
147
|
+
* than using the native-hook pid marker rail. */
|
|
147
148
|
export function requireBackend(value: unknown): MetaBackend {
|
|
148
149
|
if (typeof value !== "string" || !META_BACKENDS.includes(value as MetaBackend)) {
|
|
149
150
|
throw new MetaRecordError(
|
|
@@ -208,7 +209,7 @@ function isoNow(now: Date): string {
|
|
|
208
209
|
/** The one live identity schema number. */
|
|
209
210
|
export const META_SCHEMA_VERSION_V3 = 3 as const;
|
|
210
211
|
|
|
211
|
-
/**
|
|
212
|
+
/** Every backend admitted by the one V3 record-citizen schema. */
|
|
212
213
|
export const META_CITIZEN_BACKENDS = ["claude-code", "antigravity", "codex", "pi"] as const;
|
|
213
214
|
export type MetaCitizenBackend = (typeof META_CITIZEN_BACKENDS)[number];
|
|
214
215
|
|
|
@@ -268,6 +269,64 @@ export const FRESH_CUT_COMMAND_INSTALLED = "entwurf meta-bridge-fresh-cut";
|
|
|
268
269
|
*/
|
|
269
270
|
export const FRESH_CUT_PRESCRIPTION = `\`${FRESH_CUT_COMMAND}\` (from an installed package: \`${FRESH_CUT_COMMAND_INSTALLED}\`)`;
|
|
270
271
|
|
|
272
|
+
/**
|
|
273
|
+
* THE fresh-cut EXIT CONTRACT (#54) — the store doctor's `0/1/2/3` has a sibling here.
|
|
274
|
+
*
|
|
275
|
+
* The cut used to answer `1` for three world-states that are not each other, and the
|
|
276
|
+
* documented chain `fresh-cut && setup` therefore stopped on all of them alike. What
|
|
277
|
+
* distinguishes them is not severity, it is **what already moved**:
|
|
278
|
+
*
|
|
279
|
+
* - `COMPLETE` (0) — the generation is archived (or there was none), the fresh v3
|
|
280
|
+
* store is open, and every dead/refuted marker and socket is gone. `&& setup` is
|
|
281
|
+
* exactly right here.
|
|
282
|
+
* - `NO_MOVE` (1) — the cut REFUSED before touching anything: a live or unprovable
|
|
283
|
+
* surface, an occupied archive destination, a surface that could not be read or
|
|
284
|
+
* planned, or a first rename that failed. The host is what it was; fix the named
|
|
285
|
+
* cause and re-run the same command. Nothing downstream may proceed, because the
|
|
286
|
+
* store the install refused is still there.
|
|
287
|
+
* - `USAGE` (2) — the operator asked for something this verb does not have. Same
|
|
288
|
+
* number the `-h` path and the store doctor use, so a wrapper never has to learn a
|
|
289
|
+
* second convention.
|
|
290
|
+
* - `HALF_CUT` (3) — a failure arrived AFTER at least one archive move, so the cut
|
|
291
|
+
* transition is incomplete and the fresh generation is not confirmed open. This
|
|
292
|
+
* state wants inspection before anything else runs; a re-run can finish the cut
|
|
293
|
+
* under its own stamp.
|
|
294
|
+
* - `CLEANUP_INCOMPLETE` (4) — the cut IS done: the generation moved, the fresh
|
|
295
|
+
* generation is open, install and citizen birth are no longer blocked by the
|
|
296
|
+
* store. What survived is disposable process state (marker/socket residue) that
|
|
297
|
+
* could not be unlinked. A runbook may run `setup` here but may not call the host
|
|
298
|
+
* clean. Re-running fresh-cut is safe only before a new citizen is born; after
|
|
299
|
+
* that, repair the named residue manually rather than archiving the new generation.
|
|
300
|
+
*
|
|
301
|
+
* Still fail-closed: only `COMPLETE` is zero. #54 asked for the states to become
|
|
302
|
+
* distinguishable, not for cleanup failure to become success.
|
|
303
|
+
*
|
|
304
|
+
* Frozen numbers. A caller — a shell runbook, CI, another agent — reads these, so
|
|
305
|
+
* renumbering is a breaking change to every chain that branches on them.
|
|
306
|
+
*/
|
|
307
|
+
export const FRESH_CUT_EXIT = {
|
|
308
|
+
COMPLETE: 0,
|
|
309
|
+
NO_MOVE: 1,
|
|
310
|
+
USAGE: 2,
|
|
311
|
+
HALF_CUT: 3,
|
|
312
|
+
CLEANUP_INCOMPLETE: 4,
|
|
313
|
+
} as const;
|
|
314
|
+
|
|
315
|
+
export type FreshCutExit = (typeof FRESH_CUT_EXIT)[keyof typeof FRESH_CUT_EXIT];
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The mid-cut verdict, as a function rather than a branch, so the distinction #54 is
|
|
319
|
+
* about can be EXECUTED by a gate instead of grepped for.
|
|
320
|
+
*
|
|
321
|
+
* A rename that fails is not one event: if nothing had moved yet it is a refusal that
|
|
322
|
+
* happens to arrive late ({@link FRESH_CUT_EXIT.NO_MOVE} — the host is untouched), and
|
|
323
|
+
* if something had, it is a genuine {@link FRESH_CUT_EXIT.HALF_CUT}. The old code said
|
|
324
|
+
* both of those in prose and neither of them in its exit status.
|
|
325
|
+
*/
|
|
326
|
+
export function midCutExit(archivedCount: number): typeof FRESH_CUT_EXIT.NO_MOVE | typeof FRESH_CUT_EXIT.HALF_CUT {
|
|
327
|
+
return archivedCount === 0 ? FRESH_CUT_EXIT.NO_MOVE : FRESH_CUT_EXIT.HALF_CUT;
|
|
328
|
+
}
|
|
329
|
+
|
|
271
330
|
/** The uniform "this record is not a live-generation v3 record" error, naming the fresh-cut fix. */
|
|
272
331
|
function nonV3RecordMessage(version: unknown): string {
|
|
273
332
|
return (
|
|
@@ -452,15 +511,11 @@ export function parseMetaIdentity(json: string): MetaIdentity {
|
|
|
452
511
|
// const for the three existing backends (the drift guard) and COVERS exactly
|
|
453
512
|
// META_CITIZEN_BACKENDS (pi included).
|
|
454
513
|
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
// pi's dormant→resume→mailbox path is self-fetch-shaped, so pi is really
|
|
461
|
-
// BIMODAL; a single wakeMode field cannot express both. Splitting it
|
|
462
|
-
// (mailboxWakeMode vs controlSocketWakeMode) is out of 3C scope — for now the
|
|
463
|
-
// single field reports pi's primary live capability (direct-inject) honestly.
|
|
514
|
+
// `wakeMode` describes the last centimetre, not citizen rank. The control-socket
|
|
515
|
+
// adapter injects the body into a pi turn, so backend `pi` is `direct-inject`;
|
|
516
|
+
// Claude's mailbox is `self-fetch`; native-push adapters are direct injection.
|
|
517
|
+
// Dormant spawn-bg resume is a separate transport/ownership decision and does not
|
|
518
|
+
// turn the pi record into a mailbox citizen.
|
|
464
519
|
// ---------------------------------------------------------------------------
|
|
465
520
|
|
|
466
521
|
/** Bump only on a breaking capability-registry shape change; the parser refuses other versions. */
|
|
@@ -760,28 +815,114 @@ export function certifyActiveStore(
|
|
|
760
815
|
* the doctor and the install preflight call an unreadable host clean (2026-07-25
|
|
761
816
|
* fresh-eyes review; `existsSync` returns false for a directory it merely cannot search,
|
|
762
817
|
* which is the same laundering {@link inspectRecordEntry} refuses on the targeted path).
|
|
818
|
+
*
|
|
819
|
+
* Split out of {@link certifyActiveStoreDir} so that EVERY store-wide scan in this repo
|
|
820
|
+
* — the certification, the `entwurf_peers` listing, the rival scan — gets its entries
|
|
821
|
+
* from one function that carries the ENTRY KIND. A binding that does its own
|
|
822
|
+
* `readdir()` gets names only, so the next thing it does is `readFileSync`, and rule 1
|
|
823
|
+
* ("a symlink is refused, never followed — its bytes live where this store has no
|
|
824
|
+
* ownership") quietly stops holding on that surface. That is not hypothetical: both
|
|
825
|
+
* `entwurf_peers` bindings hand-rolled exactly that readdir, and the #52 duplicate pass
|
|
826
|
+
* then let a symlink pointing at foreign bytes quarantine a healthy regular record.
|
|
827
|
+
* The rule has to be structural, not remembered at each call site — the same lesson
|
|
828
|
+
* `pi_settings_io` learned about the settings writers.
|
|
763
829
|
*/
|
|
764
|
-
export function
|
|
830
|
+
export function readActiveStoreEntries(dir: string): ActiveStoreEntry[] {
|
|
765
831
|
const resolved = path.resolve(expandTilde(dir));
|
|
766
832
|
let dirents: fs.Dirent[];
|
|
767
833
|
try {
|
|
768
834
|
dirents = fs.readdirSync(resolved, { withFileTypes: true });
|
|
769
835
|
} catch (err) {
|
|
770
|
-
if ((err as { code?: unknown }).code === "ENOENT")
|
|
771
|
-
return { dir: resolved, scanned: 0, records: [], defects: [] };
|
|
772
|
-
}
|
|
836
|
+
if ((err as { code?: unknown }).code === "ENOENT") return [];
|
|
773
837
|
throw new MetaRecordError(
|
|
774
838
|
`cannot read the meta-record store ${resolved}: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
775
839
|
"That is a failure to inspect the store, not an empty store — refusing to certify a store this " +
|
|
776
840
|
"process cannot read.",
|
|
777
841
|
);
|
|
778
842
|
}
|
|
779
|
-
|
|
843
|
+
return dirents
|
|
780
844
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
781
845
|
.map((entry) => ({ filename: entry.name, regularFile: entry.isFile() }));
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** The errno {@link readStoreRecordFile} raises for an entry whose OPEN succeeded but
|
|
849
|
+
* whose file description is not a regular file — a directory, a fifo, a device. There is
|
|
850
|
+
* no operating-system errno for "you opened the wrong KIND of thing", so this is a
|
|
851
|
+
* synthesized one; every real errno is passed through untouched. */
|
|
852
|
+
export const NOT_REGULAR_ENTRY_CODE = "ENTWURF_ENOTREG";
|
|
853
|
+
|
|
854
|
+
const O_NOFOLLOW_FLAG = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
|
855
|
+
const O_NONBLOCK_FLAG = typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0;
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Read a record's bytes with the entry KIND decided on the very file description the
|
|
859
|
+
* read will use.
|
|
860
|
+
*
|
|
861
|
+
* `lstat`-then-`readFileSync(path)` classifies one entry and reads another. Between
|
|
862
|
+
* those two syscalls the final path component can be replaced by a symlink, and the read
|
|
863
|
+
* then follows it into bytes the store does not own — rule 1 laundered by a race rather
|
|
864
|
+
* than by a missing check (2026-07-26 cross-review). A path looked up twice cannot hold
|
|
865
|
+
* the rule; one open file description can:
|
|
866
|
+
*
|
|
867
|
+
* `O_NOFOLLOW` — a symlink at the FINAL component fails the OPEN with `ELOOP`, so
|
|
868
|
+
* foreign bytes are refused before one of them is read. Intermediate
|
|
869
|
+
* components resolve as they always have: the shape of the store
|
|
870
|
+
* DIRECTORY is a separate question, and this seam does not widen into
|
|
871
|
+
* it.
|
|
872
|
+
* `O_NONBLOCK` — opening a fifo `O_RDONLY` BLOCKS until some writer appears, so a
|
|
873
|
+
* named pipe dropped in the store would hang every scan on a host where
|
|
874
|
+
* nobody is writing. Classify-then-open never had that window; this flag
|
|
875
|
+
* is what buys it back. It is a no-op for regular files, and it does not
|
|
876
|
+
* decide anything — the fstat below is what refuses the fifo.
|
|
877
|
+
* `fstat(fd)` — the kind of THIS description, not of a name. A directory opens fine on
|
|
878
|
+
* Linux, so without it the refusal would arrive later as a stranger
|
|
879
|
+
* errno from the read.
|
|
880
|
+
*
|
|
881
|
+
* Errno is deliberately NOT flattened here. The callers separate raced-away (`ENOENT`)
|
|
882
|
+
* from unreadable (`EACCES`) from refused (`ELOOP`), and a wrapper that turned all three
|
|
883
|
+
* into one `MetaRecordError` would silently kill the rival scan's raced-away skip — the
|
|
884
|
+
* loudest possible regression, since every concurrent birth would then refuse dispatch.
|
|
885
|
+
* Shaping a message is the job of the caller that knows what the answer is FOR.
|
|
886
|
+
*/
|
|
887
|
+
export function readStoreRecordFile(file: string): string {
|
|
888
|
+
if (O_NOFOLLOW_FLAG === 0) {
|
|
889
|
+
throw new MetaRecordError(
|
|
890
|
+
`cannot read meta-record ${path.basename(file)} safely: this platform exposes no O_NOFOLLOW, so a record ` +
|
|
891
|
+
"read cannot refuse a symlink swapped in at the final component. entwurf certifies Linux only.",
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | O_NOFOLLOW_FLAG | O_NONBLOCK_FLAG);
|
|
895
|
+
try {
|
|
896
|
+
if (!fs.fstatSync(fd).isFile()) {
|
|
897
|
+
const err = new Error(
|
|
898
|
+
`meta-record ${path.basename(file)} is not a regular file (directory/fifo/device) — a record's bytes ` +
|
|
899
|
+
"must live in the store itself, so this entry is never read.",
|
|
900
|
+
) as Error & { code: string };
|
|
901
|
+
err.code = NOT_REGULAR_ENTRY_CODE;
|
|
902
|
+
throw err;
|
|
903
|
+
}
|
|
904
|
+
return fs.readFileSync(fd, "utf8");
|
|
905
|
+
} finally {
|
|
906
|
+
fs.closeSync(fd);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** The reader every store-WIDE scan pairs with {@link readActiveStoreEntries}. The
|
|
911
|
+
* caller's entry kind decides the POLICY — whether a non-regular entry is a defect
|
|
912
|
+
* (certification), a diagnostic (listing) or a non-candidate (the rival scan) — because
|
|
913
|
+
* only the caller knows that. What the caller cannot know is whether the entry is still
|
|
914
|
+
* that kind at the instant of the read, so {@link readStoreRecordFile} re-decides it on
|
|
915
|
+
* the fd and refuses there. Snapshot kind for the verdict, fd kind for the bytes. */
|
|
916
|
+
export function makeStoreRecordReader(dir: string): (filename: string) => string {
|
|
917
|
+
const resolved = path.resolve(expandTilde(dir));
|
|
918
|
+
return (filename: string) => readStoreRecordFile(path.join(resolved, filename));
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
export function certifyActiveStoreDir(dir: string): ActiveStoreCertification & { dir: string } {
|
|
922
|
+
const resolved = path.resolve(expandTilde(dir));
|
|
782
923
|
return {
|
|
783
924
|
dir: resolved,
|
|
784
|
-
...certifyActiveStore(
|
|
925
|
+
...certifyActiveStore(readActiveStoreEntries(resolved), makeStoreRecordReader(resolved)),
|
|
785
926
|
};
|
|
786
927
|
}
|
|
787
928
|
|
|
@@ -823,6 +964,24 @@ export interface ListIdentitiesResult {
|
|
|
823
964
|
errors: MetaRecordReadError[];
|
|
824
965
|
}
|
|
825
966
|
|
|
967
|
+
/**
|
|
968
|
+
* Group already-parsed identities by the id that must be unique across a store.
|
|
969
|
+
* Insertion-ordered (Map), so every consumer reports rivals in the order it read them.
|
|
970
|
+
*
|
|
971
|
+
* Keeps the listing's duplicate pass deterministic and separate from its quarantine
|
|
972
|
+
* loop. Certification and the addressable read apply the same `nativeSessionId` equality
|
|
973
|
+
* rule at different granularities, but do not call this listing-specific grouping helper.
|
|
974
|
+
*/
|
|
975
|
+
function groupByNativeSessionId(identities: readonly MetaIdentity[]): Map<string, MetaIdentity[]> {
|
|
976
|
+
const byNative = new Map<string, MetaIdentity[]>();
|
|
977
|
+
for (const identity of identities) {
|
|
978
|
+
const seen = byNative.get(identity.nativeSessionId);
|
|
979
|
+
if (seen) seen.push(identity);
|
|
980
|
+
else byNative.set(identity.nativeSessionId, [identity]);
|
|
981
|
+
}
|
|
982
|
+
return byNative;
|
|
983
|
+
}
|
|
984
|
+
|
|
826
985
|
/**
|
|
827
986
|
* Scan every meta-record in a store into identities + explicit read errors.
|
|
828
987
|
* Pure over injected (entries, readRecord) so gates drive it without IO; the
|
|
@@ -837,18 +996,41 @@ export interface ListIdentitiesResult {
|
|
|
837
996
|
* is impossible: the filename IS `<gardenId>.meta.json`, so the filesystem
|
|
838
997
|
* already enforces uniqueness — only body/filename drift can split authority.
|
|
839
998
|
*
|
|
999
|
+
* It takes {@link ActiveStoreEntry} — filename PLUS kind — for the same reason the
|
|
1000
|
+
* certification does: rule 1 says a symlinked record is refused and NEVER FOLLOWED, and
|
|
1001
|
+
* a scan handed bare names cannot obey that, because the only thing it can do next is
|
|
1002
|
+
* read the path. Both `entwurf_peers` bindings used to hand-roll a name-only readdir,
|
|
1003
|
+
* so this surface read foreign bytes through a symlink while the doctor refused the
|
|
1004
|
+
* same entry — one store, two contracts again. A non-regular entry is now a diagnostic
|
|
1005
|
+
* whose bytes are never touched.
|
|
1006
|
+
*
|
|
1007
|
+
* Store-wide UNIQUENESS of `nativeSessionId` is enforced here as well (#52) — free,
|
|
1008
|
+
* because this function already reads the whole store. See the loop below for why both
|
|
1009
|
+
* rivals become errors rather than one becoming a winner.
|
|
1010
|
+
*
|
|
840
1011
|
* mode "collect" (default) returns partial results; "strict" throws if ANY
|
|
841
1012
|
* record was unreadable (doctor / gate callers wanting all-or-nothing).
|
|
842
1013
|
*/
|
|
843
1014
|
export function listAllMetaIdentities(
|
|
844
|
-
entries: readonly
|
|
1015
|
+
entries: readonly ActiveStoreEntry[],
|
|
845
1016
|
readRecord: (filename: string) => string,
|
|
846
1017
|
opts: { mode?: "collect" | "strict" } = {},
|
|
847
1018
|
): ListIdentitiesResult {
|
|
848
1019
|
const identities: MetaIdentity[] = [];
|
|
849
1020
|
const errors: MetaRecordReadError[] = [];
|
|
850
|
-
for (const
|
|
1021
|
+
for (const entry of entries) {
|
|
1022
|
+
const filename = entry.filename;
|
|
851
1023
|
if (!filename.endsWith(".meta.json")) continue;
|
|
1024
|
+
if (!entry.regularFile) {
|
|
1025
|
+
errors.push({
|
|
1026
|
+
filename,
|
|
1027
|
+
message:
|
|
1028
|
+
"not a regular file (symlink/directory/special) — a record's bytes must live in the store " +
|
|
1029
|
+
"itself, so this entry is never followed and cannot name a citizen. Inspect and remove it by " +
|
|
1030
|
+
`hand, or archive the generation with ${FRESH_CUT_PRESCRIPTION}.`,
|
|
1031
|
+
});
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
852
1034
|
let identity: MetaIdentity;
|
|
853
1035
|
try {
|
|
854
1036
|
identity = parseMetaIdentity(readRecord(filename));
|
|
@@ -869,6 +1051,33 @@ export function listAllMetaIdentities(
|
|
|
869
1051
|
}
|
|
870
1052
|
identities.push(identity);
|
|
871
1053
|
}
|
|
1054
|
+
// Rule 4 of {@link certifyActiveStore}, enforced HERE too (#52). This scan already
|
|
1055
|
+
// holds every record in its hand, so uniqueness costs NOTHING to check — and a
|
|
1056
|
+
// facts surface that reports two records claiming one `nativeSessionId` as two
|
|
1057
|
+
// healthy citizens is describing a store the doctor calls uncertifiable as clean.
|
|
1058
|
+
// That is the sharpest form of the gap: not a missing guard, a WRONG FACT.
|
|
1059
|
+
//
|
|
1060
|
+
// Both rivals leave `identities` and become errors, never one winner: the store
|
|
1061
|
+
// genuinely cannot say which record owns that session, so picking either would
|
|
1062
|
+
// mint the authority the certification refuses to mint. Everyone else keeps
|
|
1063
|
+
// listing — the same rule an unreadable record follows, because one broken pair
|
|
1064
|
+
// must not blind `entwurf_peers` (the 0.10 lesson).
|
|
1065
|
+
for (const [nativeSessionId, holders] of groupByNativeSessionId(identities)) {
|
|
1066
|
+
if (holders.length < 2) continue;
|
|
1067
|
+
const files = holders.map((h) => metaRecordFilename(h));
|
|
1068
|
+
for (const holder of holders) {
|
|
1069
|
+
const index = identities.indexOf(holder);
|
|
1070
|
+
if (index >= 0) identities.splice(index, 1);
|
|
1071
|
+
errors.push({
|
|
1072
|
+
filename: metaRecordFilename(holder),
|
|
1073
|
+
message:
|
|
1074
|
+
`duplicate nativeSessionId ${JSON.stringify(nativeSessionId)} — also claimed by ` +
|
|
1075
|
+
`${files.filter((f) => f !== metaRecordFilename(holder)).join(", ")}. The native→garden mapping ` +
|
|
1076
|
+
`must be unique; this store cannot say which record owns that session, so NEITHER is listed as ` +
|
|
1077
|
+
`a citizen. Archive the generation with ${FRESH_CUT_PRESCRIPTION}.`,
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
872
1081
|
if (opts.mode === "strict" && errors.length > 0) {
|
|
873
1082
|
throw new MetaRecordError(
|
|
874
1083
|
`listAllMetaIdentities(strict): ${errors.length} unreadable meta-record(s): ${errors
|
|
@@ -1136,6 +1345,84 @@ export function startKeyScheme(key: string): StartKeyScheme | null {
|
|
|
1136
1345
|
return null;
|
|
1137
1346
|
}
|
|
1138
1347
|
|
|
1348
|
+
/**
|
|
1349
|
+
* CAN this pid own a native session at all? — the question one layer ABOVE
|
|
1350
|
+
* {@link classifyMarkerOwner}, and the one no marker consumer used to ask.
|
|
1351
|
+
*
|
|
1352
|
+
* `classifyMarkerOwner` answers "is the process this marker NAMES still the one
|
|
1353
|
+
* running", and for pid 1 the honest answer is YES: init is up for as long as the
|
|
1354
|
+
* host is, and its start-key does not change while it runs. So an `ownerPid: 1`
|
|
1355
|
+
* marker classifies `live` for the whole boot, and — this is the operational
|
|
1356
|
+
* point — THE ACTION THE REFUSAL PRESCRIBES CANNOT CHANGE THAT. Quiescing every
|
|
1357
|
+
* session leaves init running under the same start-key, so the operator does the
|
|
1358
|
+
* one thing they were told to do and the cut refuses again. (Deleting the marker
|
|
1359
|
+
* removes the CLAIM; it never refutes the verdict — which is exactly why the
|
|
1360
|
+
* affected host's only way out was a hand `rm`.) A reboot is not a dependable
|
|
1361
|
+
* remedy either: pid 1's key is `linux:<starttime in ticks since boot>` and init
|
|
1362
|
+
* starts within a few ticks, so the recorded value can simply come up again —
|
|
1363
|
+
* there is no contract in either direction, and this is not evidence that it must.
|
|
1364
|
+
* What was MEASURED (#53 A, second Linux host, 2026-07-25) is that the cut stayed
|
|
1365
|
+
* blocked until the file was deleted by hand, while `0.12.8` names that same cut
|
|
1366
|
+
* as the one repair for a pre-v3 store: the documented upgrade path had no in-band
|
|
1367
|
+
* exit. The pure rule is not wrong there; the marker's CLAIM is.
|
|
1368
|
+
*
|
|
1369
|
+
* WHERE SUCH A MARKER COMES FROM — stated at the size of the evidence. After this
|
|
1370
|
+
* fix no writer in THIS tree can mint one, so on a current install it is legacy or
|
|
1371
|
+
* corrupt residue. Legacy has more than one source: the retired shell-form Claude
|
|
1372
|
+
* hook (wrapper shell exits first → hook REPARENTED to init → reads `ppid = 1`),
|
|
1373
|
+
* and the agy imprint, which asked only `> 0` until #53 A and could mint the same
|
|
1374
|
+
* shape through the same reparenting. Corrupt is a real class too — this predicate
|
|
1375
|
+
* also refutes non-integer and unsafe-integer pids, which no writer here has ever
|
|
1376
|
+
* produced, so a foreign or damaged marker is the only way they appear. The ONE
|
|
1377
|
+
* file actually observed was a shell-form Claude hook reparented to init. None of
|
|
1378
|
+
* these is a zombie: a zombie is reaped, its pid is freed, the start-key stops
|
|
1379
|
+
* matching, and the marker resolves itself.
|
|
1380
|
+
*
|
|
1381
|
+
* A pid ≤ 1 is refuted BY CONSTRUCTION, which is why this predicate is shared by
|
|
1382
|
+
* both writers and every reader instead of living at one call site: 0 and
|
|
1383
|
+
* negatives address process GROUPS rather than a process (the rule
|
|
1384
|
+
* {@link probePidExistence} already holds), and on the axis this repo certifies no
|
|
1385
|
+
* native session is owned by init.
|
|
1386
|
+
*
|
|
1387
|
+
* THAT LAST CLAUSE IS A POLICY, NOT A LAW OF PROCESSES. The certified axis is a
|
|
1388
|
+
* Linux desktop/workstation host, where init is the service manager and every
|
|
1389
|
+
* native harness is a descendant of a login session. A container that runs the
|
|
1390
|
+
* harness AS pid 1 is a real shape in the world, and there `ownerPid: 1` would name
|
|
1391
|
+
* a genuine owner — so such a host is simply UNSUPPORTED here and fails CLOSED: the
|
|
1392
|
+
* writers refuse to mint the marker, so the session keeps its meta-record but never
|
|
1393
|
+
* claims reply-addressability, which costs a capability instead of granting a false
|
|
1394
|
+
* identity. Do not read this predicate as "pid 1 can never own anything"; read it as
|
|
1395
|
+
* "this repo does not certify a host where it does". Widening the axis means new
|
|
1396
|
+
* evidence and a new contract, not a quiet loosening of the bound.
|
|
1397
|
+
*
|
|
1398
|
+
* Writers refuse to mint it, readers refuse to honor it, and the cut treats it as
|
|
1399
|
+
* clearable residue rather than an owner claim — a proof of INVALIDITY, which is
|
|
1400
|
+
* stronger than the proof of death the cut already acts on.
|
|
1401
|
+
*/
|
|
1402
|
+
export function isPlausibleOwnerPid(pid: unknown): pid is number {
|
|
1403
|
+
return Number.isSafeInteger(pid) && (pid as number) > 1;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* The write-side half of {@link isPlausibleOwnerPid}. A marker naming an
|
|
1408
|
+
* impossible owner is not a degraded marker, it is a lie that outlives every
|
|
1409
|
+
* process that could refute it — so minting one THROWS rather than warns, and no
|
|
1410
|
+
* future writer can reintroduce #53 A by forgetting the predicate at its own call
|
|
1411
|
+
* site (both current writers still ask it first, and fail closed in their own
|
|
1412
|
+
* words).
|
|
1413
|
+
*/
|
|
1414
|
+
function requireOwnerPid(ownerPid: number, kind: string): number {
|
|
1415
|
+
if (!isPlausibleOwnerPid(ownerPid)) {
|
|
1416
|
+
throw new Error(
|
|
1417
|
+
`refusing to write a ${kind} marker for owner pid ${ownerPid}: a pid <= 1 cannot own a native session ` +
|
|
1418
|
+
"(pid 1 is init — a reparented orphan is not an owner). Quiescing the sessions, which is what " +
|
|
1419
|
+
"meta-bridge-fresh-cut asks for when it refuses, would not refute such a marker, so it would keep " +
|
|
1420
|
+
"blocking the cut on this host (#53 A).",
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1423
|
+
return ownerPid;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1139
1426
|
/**
|
|
1140
1427
|
* THE rule for "is the process this marker names still the one running?" — the one
|
|
1141
1428
|
* place a `dead` verdict may be produced, so no caller re-derives it from
|
|
@@ -1227,15 +1514,18 @@ export interface WriteMetaSenderMarkerOptions {
|
|
|
1227
1514
|
export function writeMetaSenderMarker(opts: WriteMetaSenderMarkerOptions): string {
|
|
1228
1515
|
const backend = requireBackend(opts.backend);
|
|
1229
1516
|
const gardenId = requireGardenId(opts.gardenId);
|
|
1230
|
-
|
|
1517
|
+
// Refused BEFORE the path is built: an impossible owner must not even leave a
|
|
1518
|
+
// backend directory behind, let alone a marker keyed to its pid.
|
|
1519
|
+
const ownerPid = requireOwnerPid(opts.ownerPid, "sender");
|
|
1520
|
+
const file = metaSenderMarkerPath(backend, ownerPid, opts.sendersDir ?? defaultMetaSendersDir());
|
|
1231
1521
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1232
1522
|
const marker: MetaSenderMarker = {
|
|
1233
1523
|
backend,
|
|
1234
1524
|
gardenId,
|
|
1235
1525
|
nativeSessionId: requireNonEmptyString(opts.nativeSessionId, "nativeSessionId"),
|
|
1236
1526
|
cwd: requireNonEmptyString(opts.cwd, "cwd"),
|
|
1237
|
-
ownerPid
|
|
1238
|
-
ownerStartKey: processStartKey(
|
|
1527
|
+
ownerPid,
|
|
1528
|
+
ownerStartKey: processStartKey(ownerPid),
|
|
1239
1529
|
updatedAt: isoNow(opts.now ?? new Date()),
|
|
1240
1530
|
};
|
|
1241
1531
|
const tmp = `${file}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
@@ -1278,12 +1568,18 @@ export function readMetaSenderMarker(opts: ReadMetaSenderMarkerOptions): MetaSen
|
|
|
1278
1568
|
ownerStartKey: requireNonEmptyString(raw.ownerStartKey, "ownerStartKey"),
|
|
1279
1569
|
updatedAt: requireNonEmptyString(raw.updatedAt, "updatedAt"),
|
|
1280
1570
|
};
|
|
1571
|
+
// Plausibility comes FIRST and is never opt-out: it is a property of the CLAIM,
|
|
1572
|
+
// not of the owner's current state, so `verifyOwner: false` (inspection) does not
|
|
1573
|
+
// reach past it either. An `ownerPid: 1` marker is a reparented orphan's residue
|
|
1574
|
+
// and would otherwise keep granting a dead citizen's sender identity for as long
|
|
1575
|
+
// as the host is up — the pid-reuse guard cannot catch it, because init IS still
|
|
1576
|
+
// the same process (#53 A). This also subsumes the old `Number.isInteger` check.
|
|
1577
|
+
if (!isPlausibleOwnerPid(marker.ownerPid)) return null;
|
|
1281
1578
|
// pid-reuse guard (unless explicitly disabled): the owner pid must STILL be
|
|
1282
1579
|
// the same process that wrote the marker. A bare pid is reused; pid+startKey
|
|
1283
1580
|
// is boot-unique, so a stale marker from a dead session fails here instead of
|
|
1284
1581
|
// granting a wrong-identity send.
|
|
1285
1582
|
if (opts.verifyOwner !== false) {
|
|
1286
|
-
if (!Number.isInteger(marker.ownerPid)) return null;
|
|
1287
1583
|
const liveKey = processStartKey(marker.ownerPid);
|
|
1288
1584
|
if (!liveKey || liveKey !== marker.ownerStartKey) return null;
|
|
1289
1585
|
}
|
|
@@ -1349,14 +1645,15 @@ export interface WriteMetaReceiverMarkerOptions {
|
|
|
1349
1645
|
export function writeMetaReceiverMarker(opts: WriteMetaReceiverMarkerOptions): string {
|
|
1350
1646
|
const gardenId = requireGardenId(opts.gardenId);
|
|
1351
1647
|
const backend = requireBackend(opts.backend);
|
|
1648
|
+
const ownerPid = requireOwnerPid(opts.ownerPid, "receiver");
|
|
1352
1649
|
const file = metaReceiverMarkerPath(gardenId, opts.receiversDir ?? defaultMetaReceiversDir());
|
|
1353
1650
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1354
1651
|
const marker: MetaReceiverMarker = {
|
|
1355
1652
|
gardenId,
|
|
1356
1653
|
backend,
|
|
1357
1654
|
nativeSessionId: requireNonEmptyString(opts.nativeSessionId, "nativeSessionId"),
|
|
1358
|
-
ownerPid
|
|
1359
|
-
ownerStartKey: processStartKey(
|
|
1655
|
+
ownerPid,
|
|
1656
|
+
ownerStartKey: processStartKey(ownerPid),
|
|
1360
1657
|
ownerKind: requireNonEmptyString(opts.ownerKind ?? "claude-code-cli", "ownerKind"),
|
|
1361
1658
|
armProvenance: requireArmProvenance(opts.armProvenance),
|
|
1362
1659
|
updatedAt: isoNow(opts.now ?? new Date()),
|
|
@@ -1403,8 +1700,11 @@ export function readMetaReceiverMarker(opts: ReadMetaReceiverMarkerOptions): Met
|
|
|
1403
1700
|
armProvenance: requireArmProvenance(raw.armProvenance),
|
|
1404
1701
|
updatedAt: requireNonEmptyString(raw.updatedAt, "updatedAt"),
|
|
1405
1702
|
};
|
|
1703
|
+
// Same rule as the sender marker, and for the same reason one layer over: an
|
|
1704
|
+
// impossible owner is not a live one, so a refuted marker must never read back
|
|
1705
|
+
// as an ACTIVE RECEIVER and pull the mailbox rail into delivering to a void.
|
|
1706
|
+
if (!isPlausibleOwnerPid(marker.ownerPid)) return null;
|
|
1406
1707
|
if (opts.verifyOwner !== false) {
|
|
1407
|
-
if (!Number.isInteger(marker.ownerPid)) return null;
|
|
1408
1708
|
const liveKey = processStartKey(marker.ownerPid);
|
|
1409
1709
|
if (!liveKey || liveKey !== marker.ownerStartKey) return null;
|
|
1410
1710
|
}
|
|
@@ -1516,14 +1816,62 @@ function inspectRecordEntry(file: string): RecordEntryKind {
|
|
|
1516
1816
|
return fs.lstatSync(file).isFile() ? "regular-file" : "irregular";
|
|
1517
1817
|
} catch (err) {
|
|
1518
1818
|
if ((err as { code?: unknown }).code === "ENOENT") return "absent";
|
|
1519
|
-
throw
|
|
1520
|
-
`cannot inspect meta-record ${path.basename(file)} under ${path.dirname(file)}: ` +
|
|
1521
|
-
`${err instanceof Error ? err.message : String(err)}. That is an inspection failure, not an absent ` +
|
|
1522
|
-
`citizen — refusing to report "no record" from a store this process cannot read.`,
|
|
1523
|
-
);
|
|
1819
|
+
throw recordInspectionFailure(file, err);
|
|
1524
1820
|
}
|
|
1525
1821
|
}
|
|
1526
1822
|
|
|
1823
|
+
/**
|
|
1824
|
+
* How a FAILED record read is classified when the entry was already snapshotted as a
|
|
1825
|
+
* regular file — that is, a race: the entry changed between the classification and the
|
|
1826
|
+
* open. Pure, errno-only, so the branch a synthetic errno takes is exactly the branch a
|
|
1827
|
+
* real one takes (the 2026-07-26 cross-review found the previous shape unprovable: with
|
|
1828
|
+
* the classification in front of it, no settled store could reach these branches at all,
|
|
1829
|
+
* and a cell that claimed to pin them passed with the mapping deleted).
|
|
1830
|
+
*
|
|
1831
|
+
* Every verdict here collapses onto the answer the SETTLED store already gives, because a
|
|
1832
|
+
* race must not teach the operator a second vocabulary for one state of the world:
|
|
1833
|
+
*
|
|
1834
|
+
* - `ENOENT` — the record went away mid-read. Absence, and absence alone.
|
|
1835
|
+
* - `ELOOP` — `O_NOFOLLOW` refused a symlink at the final component.
|
|
1836
|
+
* - `ENXIO` — a socket (or a device with no driver behind it) is what the name now
|
|
1837
|
+
* points at. `lstat` calls that irregular, so an open that trips over it
|
|
1838
|
+
* must say the same thing rather than "this host is unreadable".
|
|
1839
|
+
* - {@link NOT_REGULAR_ENTRY_CODE} — the fd opened, and `fstat` says it is a directory
|
|
1840
|
+
* or a fifo.
|
|
1841
|
+
* - anything else (EACCES, ENOTDIR, EIO…) — an inspection FAILURE, which stays loud and
|
|
1842
|
+
* must never be laundered into a clean "no such citizen".
|
|
1843
|
+
*/
|
|
1844
|
+
export type RecordReadFailure = "absent" | "irregular" | "unreadable";
|
|
1845
|
+
|
|
1846
|
+
export function classifyRecordReadFailure(code: unknown): RecordReadFailure {
|
|
1847
|
+
if (code === "ENOENT") return "absent";
|
|
1848
|
+
if (code === "ELOOP" || code === "ENXIO" || code === NOT_REGULAR_ENTRY_CODE) return "irregular";
|
|
1849
|
+
return "unreadable";
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/** The three refusals a targeted read can reach from EITHER layer — the settled
|
|
1853
|
+
* classification or a raced read. One text each, so a race and a quiet store say the same
|
|
1854
|
+
* sentence to the operator. */
|
|
1855
|
+
function recordInspectionFailure(file: string, err: unknown): MetaRecordError {
|
|
1856
|
+
return new MetaRecordError(
|
|
1857
|
+
`cannot inspect meta-record ${path.basename(file)} under ${path.dirname(file)}: ` +
|
|
1858
|
+
`${err instanceof Error ? err.message : String(err)}. That is an inspection failure, not an absent ` +
|
|
1859
|
+
`citizen — refusing to report "no record" from a store this process cannot read.`,
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
function absentRecordRefusal(id: string, file: string): MetaRecordError {
|
|
1863
|
+
return new MetaRecordError(
|
|
1864
|
+
`no meta-record for garden id "${id}" under ${path.dirname(file)} — not a garden citizen, cannot deliver.`,
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
function irregularRecordRefusal(id: string): MetaRecordError {
|
|
1868
|
+
return new MetaRecordError(
|
|
1869
|
+
`meta-record ${id}.meta.json is not a regular file (symlink/directory/special) — a record's bytes must ` +
|
|
1870
|
+
`live in the store itself, so this entry is never followed and cannot address a citizen. ` +
|
|
1871
|
+
`Inspect and remove it by hand, or archive the generation with ${FRESH_CUT_PRESCRIPTION}.`,
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1527
1875
|
/**
|
|
1528
1876
|
* The identity read-by-gardenId. Read the file, body is SSOT, fail-fast on
|
|
1529
1877
|
* body/filename gardenId drift; V3-only via parseMetaIdentity (an unreadable
|
|
@@ -1536,26 +1884,44 @@ export function readMetaIdentityByGardenId(
|
|
|
1536
1884
|
): MetaIdentity {
|
|
1537
1885
|
const id = requireGardenId(gardenId);
|
|
1538
1886
|
const file = recordFileFor(sessionsDir, id);
|
|
1887
|
+
// TWO LAYERS, AND THEY ARE NOT THE SAME RULE TWICE.
|
|
1888
|
+
//
|
|
1889
|
+
// Layer 1 — POLICY, on a settled store. Rule 1 of {@link certifyActiveStore}, enforced
|
|
1890
|
+
// HERE too, because this is the one place a live dispatch reads a record. Certifying
|
|
1891
|
+
// the store on WRITE while this read followed the link made the contract true only
|
|
1892
|
+
// where nobody was being addressed: the doctor refused a symlinked entry that v2
|
|
1893
|
+
// dispatch, `entwurf_self` and the sender-marker trust all resolved happily, from bytes
|
|
1894
|
+
// the store does not own (2026-07-25 fresh-eyes review). It classifies WITHOUT OPENING,
|
|
1895
|
+
// which is the point — a socket, a device, a mode-000 directory each earn the
|
|
1896
|
+
// certification's own sentence instead of whatever errno an `open` would have tripped
|
|
1897
|
+
// over (ENXIO, EACCES), and nothing special is opened to find out what it is. Deleting
|
|
1898
|
+
// this layer in favour of the fd alone looked like removing a duplicate enforcement
|
|
1899
|
+
// point and was actually a regression on all three shapes (2026-07-26 cross-review,
|
|
1900
|
+
// round 3 — found by GPT, reproduced here).
|
|
1901
|
+
//
|
|
1902
|
+
// Layer 2 — the RACE, on the bytes actually returned. A verdict about a name stops
|
|
1903
|
+
// being true the moment something replaces what the name points at, so the read
|
|
1904
|
+
// re-decides on its own fd. Its errno verdicts collapse onto layer 1's sentences via
|
|
1905
|
+
// {@link classifyRecordReadFailure}, which is pure so that the branches stay provable:
|
|
1906
|
+
// with layer 1 in front of it, no settled store can reach them.
|
|
1539
1907
|
const kind = inspectRecordEntry(file);
|
|
1540
|
-
if (kind === "absent")
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
`Inspect and remove it by hand, or archive the generation with ${FRESH_CUT_PRESCRIPTION}.`,
|
|
1556
|
-
);
|
|
1908
|
+
if (kind === "absent") throw absentRecordRefusal(id, file);
|
|
1909
|
+
if (kind === "irregular") throw irregularRecordRefusal(id);
|
|
1910
|
+
let raw: string;
|
|
1911
|
+
try {
|
|
1912
|
+
raw = readStoreRecordFile(file);
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
if (err instanceof MetaRecordError) throw err;
|
|
1915
|
+
switch (classifyRecordReadFailure((err as { code?: unknown }).code)) {
|
|
1916
|
+
case "absent":
|
|
1917
|
+
throw absentRecordRefusal(id, file);
|
|
1918
|
+
case "irregular":
|
|
1919
|
+
throw irregularRecordRefusal(id);
|
|
1920
|
+
default:
|
|
1921
|
+
throw recordInspectionFailure(file, err);
|
|
1922
|
+
}
|
|
1557
1923
|
}
|
|
1558
|
-
const identity = parseMetaIdentity(
|
|
1924
|
+
const identity = parseMetaIdentity(raw);
|
|
1559
1925
|
if (identity.gardenId !== id) {
|
|
1560
1926
|
throw new MetaRecordError(
|
|
1561
1927
|
`meta-record body/filename drift: ${id}.meta.json contains gardenId "${identity.gardenId}". ` +
|
|
@@ -1586,6 +1952,125 @@ export function metaRecordExistsByGardenId(gardenId: string, sessionsDir: string
|
|
|
1586
1952
|
return inspectRecordEntry(recordFileFor(sessionsDir, id)) !== "absent";
|
|
1587
1953
|
}
|
|
1588
1954
|
|
|
1955
|
+
/**
|
|
1956
|
+
* Pure: which OTHER records in this store claim `identity`'s `nativeSessionId`.
|
|
1957
|
+
* Returns their filenames, sorted; empty means `identity` holds it alone.
|
|
1958
|
+
*
|
|
1959
|
+
* A RIVAL IS A RECORD THAT COULD BE ADDRESSED INSTEAD. That is the whole test, and it
|
|
1960
|
+
* is narrower than "a file whose bytes mention the same id" — three neighbour shapes
|
|
1961
|
+
* are therefore NOT candidates, and skipping them is a claim about reachability, not
|
|
1962
|
+
* leniency:
|
|
1963
|
+
*
|
|
1964
|
+
* - NON-REGULAR (symlink/dir/special): rule 1 of {@link certifyActiveStore} says such
|
|
1965
|
+
* an entry is refused and never followed, because its bytes live where this store
|
|
1966
|
+
* has no ownership. So it is skipped WITHOUT BEING READ — following it to see
|
|
1967
|
+
* whether it "counts" would break the rule in the act of enforcing it, and let a
|
|
1968
|
+
* planted symlink to foreign bytes quarantine a healthy citizen.
|
|
1969
|
+
* - BODY/FILENAME DRIFT: a record whose body names a different garden id is
|
|
1970
|
+
* unreachable by garden-id lookup from either name, so it can never be dispatched
|
|
1971
|
+
* to and cannot compete for an address.
|
|
1972
|
+
* - UNPARSEABLE by the live schema: same reason — no read path can reach it.
|
|
1973
|
+
*
|
|
1974
|
+
* All three are real certification defects, and the certification and the listing both
|
|
1975
|
+
* say so. What they must not do is blind a healthy citizen, which is the 0.10 "corrupt
|
|
1976
|
+
* blocks registration forever" mistake wearing a new hat.
|
|
1977
|
+
*
|
|
1978
|
+
* A CANDIDATE WE COULD NOT READ IS A DIFFERENT ANSWER AND THROWS. A regular
|
|
1979
|
+
* `.meta.json` this process cannot read might be a genuine duplicate; skipping it would
|
|
1980
|
+
* report "holds it alone" from a scan that never asked, which is exactly the vacuous
|
|
1981
|
+
* pass the store-level readdir guard refuses one level up. ENOENT is the one exception
|
|
1982
|
+
* and the one this repo already recognises everywhere (`inspectRecordEntry`, the cut's
|
|
1983
|
+
* socket walk, `clearFiles`): a file that vanished between the readdir and the read is
|
|
1984
|
+
* not in the store, so it holds nothing.
|
|
1985
|
+
*
|
|
1986
|
+
* The identity's OWN file is excluded by filename, not by identity equality: the caller
|
|
1987
|
+
* has already proven body and filename agree, and a rival is by definition a different
|
|
1988
|
+
* file.
|
|
1989
|
+
*/
|
|
1990
|
+
export function nativeSessionIdRivals(
|
|
1991
|
+
identity: MetaIdentity,
|
|
1992
|
+
entries: readonly ActiveStoreEntry[],
|
|
1993
|
+
readRecord: (filename: string) => string,
|
|
1994
|
+
): string[] {
|
|
1995
|
+
const own = metaRecordFilename(identity);
|
|
1996
|
+
const rivals: string[] = [];
|
|
1997
|
+
for (const entry of entries) {
|
|
1998
|
+
const filename = entry.filename;
|
|
1999
|
+
if (!filename.endsWith(".meta.json") || filename === own) continue;
|
|
2000
|
+
if (!entry.regularFile) continue; // never followed — see rule 1 above
|
|
2001
|
+
let raw: string;
|
|
2002
|
+
try {
|
|
2003
|
+
raw = readRecord(filename);
|
|
2004
|
+
} catch (err) {
|
|
2005
|
+
if ((err as { code?: unknown }).code === "ENOENT") continue; // raced away — holds nothing
|
|
2006
|
+
throw new MetaRecordError(
|
|
2007
|
+
`cannot read meta-record ${filename} while proving that ${own} holds nativeSessionId ` +
|
|
2008
|
+
`${JSON.stringify(identity.nativeSessionId)} alone: ` +
|
|
2009
|
+
`${err instanceof Error ? err.message : String(err)}. That record may be a duplicate, so this ` +
|
|
2010
|
+
`is an unanswered question, not a clean scan — refusing to dispatch at an address this process ` +
|
|
2011
|
+
`cannot certify.`,
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
2014
|
+
let other: MetaIdentity;
|
|
2015
|
+
try {
|
|
2016
|
+
other = parseMetaIdentity(raw);
|
|
2017
|
+
} catch {
|
|
2018
|
+
continue; // unreachable by the live schema — not an addressable rival
|
|
2019
|
+
}
|
|
2020
|
+
if (metaRecordFilename(other) !== filename) continue; // drifted — unreachable by garden id
|
|
2021
|
+
if (other.nativeSessionId === identity.nativeSessionId) rivals.push(filename);
|
|
2022
|
+
}
|
|
2023
|
+
return rivals.sort();
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
/**
|
|
2027
|
+
* The read a DISPATCH does (#52) — the targeted read PLUS the store-wide half of the
|
|
2028
|
+
* contract that a targeted read cannot see on its own.
|
|
2029
|
+
*
|
|
2030
|
+
* WHY THIS IS A SECOND FUNCTION AND NOT A CHANGE TO {@link readMetaIdentityByGardenId}:
|
|
2031
|
+
* the cost is real and the README says so out loud — a call-relay does not re-scan the
|
|
2032
|
+
* whole store per message, and the mailbox poke, the sender-marker trust and
|
|
2033
|
+
* `entwurf_self` keep the per-entry half exactly as before. What separates the callers
|
|
2034
|
+
* is not how careful they are, it is what they DO with the answer: these two turn
|
|
2035
|
+
* `nativeSessionId` into an ADDRESS — a native-push injection into a live conversation,
|
|
2036
|
+
* a pi resume against a transcript — and each does it ONCE per dispatch, next to a
|
|
2037
|
+
* socket connect and a process spawn. One readdir there is nothing; the same readdir
|
|
2038
|
+
* per relayed message is the design the store deliberately does not have.
|
|
2039
|
+
*
|
|
2040
|
+
* The failure it prevents is not hypothetical corruption. `upsertMetaSession` certifies
|
|
2041
|
+
* and then writes, which is not a transaction: two concurrent births — two SessionStart
|
|
2042
|
+
* hooks, an `entwurf_register_native` racing an agy imprint — can both read one clean
|
|
2043
|
+
* snapshot and mint DIFFERENT garden ids for one native session. Nothing was ever
|
|
2044
|
+
* corrupted on such a host, and yet both ids would direct-inject the same conversation,
|
|
2045
|
+
* or resume one transcript twice under two per-garden-id locks.
|
|
2046
|
+
*
|
|
2047
|
+
* Fails LOUD, never soft: a duplicate is corruption of the address space, and QB1
|
|
2048
|
+
* reserves the soft `bad-target` answer for a record that is simply ABSENT.
|
|
2049
|
+
*/
|
|
2050
|
+
export function readAddressableMetaIdentity(
|
|
2051
|
+
gardenId: string,
|
|
2052
|
+
sessionsDir: string = defaultMetaSessionsDir(),
|
|
2053
|
+
): MetaIdentity {
|
|
2054
|
+
const identity = readMetaIdentityByGardenId(gardenId, sessionsDir);
|
|
2055
|
+
const dir = path.resolve(expandTilde(sessionsDir));
|
|
2056
|
+
// Entries carry their KIND (readActiveStoreEntries), so the rival scan can refuse a
|
|
2057
|
+
// symlinked neighbour without reading it. A bare-name readdir here would have made
|
|
2058
|
+
// rule 1 unenforceable at exactly the surface that turns a record into an address.
|
|
2059
|
+
// A store that cannot be listed throws from there — answering "unique" from a scan
|
|
2060
|
+
// that never happened is the vacuous pass this whole check exists to refuse.
|
|
2061
|
+
const rivals = nativeSessionIdRivals(identity, readActiveStoreEntries(dir), makeStoreRecordReader(dir));
|
|
2062
|
+
if (rivals.length > 0) {
|
|
2063
|
+
throw new MetaRecordError(
|
|
2064
|
+
`meta-record ${metaRecordFilename(identity)} shares nativeSessionId ` +
|
|
2065
|
+
`${JSON.stringify(identity.nativeSessionId)} with ${rivals.join(", ")} — the native→garden mapping ` +
|
|
2066
|
+
`must be unique, so this store cannot say which record owns that session and dispatching at either ` +
|
|
2067
|
+
`garden id would reach the same native session twice. Archive the generation with ` +
|
|
2068
|
+
`${FRESH_CUT_PRESCRIPTION}.`,
|
|
2069
|
+
);
|
|
2070
|
+
}
|
|
2071
|
+
return identity;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
1589
2074
|
export interface EnqueueMetaMessageOptions {
|
|
1590
2075
|
gardenId: string;
|
|
1591
2076
|
body: string;
|