@awebai/oats 0.23.2 → 0.24.0

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.
Files changed (158) hide show
  1. package/README.md +7 -4
  2. package/bin/oats-pi-sdk-host.mjs +17 -0
  3. package/bin/oats.mjs +442 -51
  4. package/capabilities/oats-okf/bin/oats-okf-binding.mjs +14 -0
  5. package/capabilities/oats-okf/bin/oats-okf.mjs +79 -11
  6. package/capabilities/oats-okf/lib/binding-wire.mjs +268 -0
  7. package/capabilities/oats-okf/lib/captured-worker.mjs +101 -0
  8. package/capabilities/oats-okf/lib/config.mjs +2 -1
  9. package/capabilities/oats-okf/lib/inspection.mjs +16 -1
  10. package/capabilities/oats-okf/lib/invocation-context.mjs +111 -0
  11. package/capabilities/oats-okf/lib/invocation-shape.mjs +135 -0
  12. package/capabilities/oats-okf/lib/io.mjs +1 -1
  13. package/capabilities/oats-okf/lib/portable-binding.mjs +199 -0
  14. package/capabilities/oats-okf/lib/source-contract.mjs +46 -0
  15. package/capabilities/oats-okf/lib/sources.mjs +123 -3
  16. package/capabilities/oats-okf/lib/stores.mjs +104 -25
  17. package/capabilities/oats-okf/lib/worker.mjs +69 -10
  18. package/capabilities/oats-okf/oats.json +35 -7
  19. package/capabilities/oats-okf/schemas/okf-portable-declaration.schema.json +87 -0
  20. package/capabilities/oats-okf/schemas/okf-portable-payload.schema.json +113 -0
  21. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +23 -5
  22. package/capabilities/oats-okf/skills/okf/SKILL.md +42 -1
  23. package/docs/artifact-approvals.schema.json +7 -0
  24. package/docs/capability-manifest.schema.json +37 -66
  25. package/docs/captured-invocation-context.schema.json +7 -0
  26. package/docs/captured-resolution.schema.json +7 -0
  27. package/docs/design/2026-09-14-artifact-retention-contract.md +190 -0
  28. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +708 -0
  29. package/docs/design/2026-09-14-portable-souls-contract-amendments.md +85 -0
  30. package/docs/design/2026-09-14-portable-souls-explainer.md +750 -0
  31. package/docs/design/2026-09-15-captured-dispatch.md +127 -0
  32. package/docs/design/2026-09-15-captured-resolution-records.md +143 -0
  33. package/docs/design/2026-09-15-package-preparation.md +100 -0
  34. package/docs/design/2026-09-15-portable-data-contract.md +121 -0
  35. package/docs/design/2026-09-15-portable-declarations.md +189 -0
  36. package/docs/design/2026-09-15-portable-souls-handoff.md +150 -0
  37. package/docs/design/2026-09-15-portable-souls-implementation.md +417 -0
  38. package/docs/design/2026-09-15-selection-lock-and-approval.md +122 -0
  39. package/docs/design/2026-09-15-source-observation.md +119 -0
  40. package/docs/design/2026-09-16-captured-admission.md +77 -0
  41. package/docs/design/2026-09-16-captured-helper-dispatch.md +105 -0
  42. package/docs/design/2026-09-16-captured-launch-inputs.md +42 -0
  43. package/docs/design/2026-09-16-command-profile-preparation.md +86 -0
  44. package/docs/design/2026-09-16-fresh-install-first-rollout.md +47 -0
  45. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +277 -0
  46. package/docs/design/2026-09-16-knowledge-capability-contract.md +61 -0
  47. package/docs/design/2026-09-16-messaging-capability-contract.md +59 -0
  48. package/docs/design/2026-09-16-portable-migration-evidence.md +156 -0
  49. package/docs/design/2026-09-16-portable-onboarding.md +177 -0
  50. package/docs/design/2026-09-16-prepare-request-transport.md +26 -0
  51. package/docs/design/2026-09-16-provider-binding-codecs.md +98 -0
  52. package/docs/design/2026-09-16-provider-binding-wire.md +247 -0
  53. package/docs/design/2026-09-17-capability-helper-input-contract.md +95 -0
  54. package/docs/design/2026-09-17-captured-backend-parity.md +53 -0
  55. package/docs/design/2026-09-17-captured-native-start.md +58 -0
  56. package/docs/design/2026-09-17-portable-boundary-hookup.md +19 -0
  57. package/docs/design/2026-09-17-portable-boundary-resources.md +52 -0
  58. package/docs/design/2026-09-17-public-captured-start.md +108 -0
  59. package/docs/design/2026-09-17-public-prepare-request.md +90 -0
  60. package/docs/design/2026-09-18-captured-pi-host.md +205 -0
  61. package/docs/design/2026-09-18-first-cut-release-checklist.md +131 -0
  62. package/docs/design/2026-09-18-herdr-protocol-compatibility.md +60 -0
  63. package/docs/desktop-cli-api.md +5 -2
  64. package/docs/execution-capsule.schema.json +108 -0
  65. package/docs/execution-targets.md +20 -7
  66. package/docs/oats-lock-v3.schema.json +7 -0
  67. package/docs/oats-member.schema.json +38 -0
  68. package/docs/oats-workspace.schema.json +68 -0
  69. package/docs/portable.schema.json +2512 -0
  70. package/docs/provider-check-input.schema.json +7 -0
  71. package/docs/release-notes/v0.24.0.md +104 -0
  72. package/docs/schedules.md +126 -14
  73. package/docs/soul.schema.json +82 -0
  74. package/injects/oats-portable.md +17 -0
  75. package/injects/portable-instance-boundary.md +39 -0
  76. package/injects/portable-work-directory.md +29 -0
  77. package/lib/artifact-approvals.mjs +120 -0
  78. package/lib/artifact-tree.mjs +141 -0
  79. package/lib/capability-artifacts.mjs +179 -0
  80. package/lib/capability-execution.mjs +15 -0
  81. package/lib/capability-inputs.mjs +39 -0
  82. package/lib/capability-provenance.mjs +231 -0
  83. package/lib/captured-action-shape.mjs +21 -0
  84. package/lib/captured-admission-shape.mjs +20 -0
  85. package/lib/captured-binding-file.mjs +36 -0
  86. package/lib/captured-dispatch.mjs +66 -0
  87. package/lib/captured-instance-index.mjs +277 -0
  88. package/lib/captured-invocation-context.mjs +130 -0
  89. package/lib/captured-launch-request.mjs +46 -0
  90. package/lib/captured-operation-process.mjs +15 -0
  91. package/lib/captured-pi-custody.mjs +29 -0
  92. package/lib/captured-pi-host.mjs +167 -0
  93. package/lib/captured-pi-outcome.mjs +172 -0
  94. package/lib/captured-resolutions.mjs +275 -0
  95. package/lib/captured-scaffold.mjs +87 -0
  96. package/lib/captured-selector.mjs +28 -0
  97. package/lib/captured-session-backend.mjs +52 -0
  98. package/lib/captured-source-receipt-file.mjs +72 -0
  99. package/lib/config-data.mjs +104 -0
  100. package/lib/core.mjs +918 -562
  101. package/lib/errors.mjs +7 -0
  102. package/lib/helper-injection-policy.mjs +98 -0
  103. package/lib/herdr.mjs +18 -7
  104. package/lib/instruction-composition.mjs +31 -0
  105. package/lib/legacy-lock-codec.mjs +106 -0
  106. package/lib/manifest-settings.mjs +84 -0
  107. package/lib/package-closure.mjs +48 -0
  108. package/lib/package-materialization.mjs +83 -0
  109. package/lib/pi-sdk-host.mjs +229 -0
  110. package/lib/portable-artifacts.mjs +115 -0
  111. package/lib/portable-choices.mjs +82 -0
  112. package/lib/portable-composition.mjs +136 -0
  113. package/lib/portable-digest.mjs +105 -0
  114. package/lib/portable-files.mjs +26 -0
  115. package/lib/portable-identity.mjs +40 -0
  116. package/lib/portable-lock.mjs +117 -0
  117. package/lib/portable-migration-artifacts.mjs +135 -0
  118. package/lib/portable-migration-evidence.mjs +305 -0
  119. package/lib/portable-migration-store.mjs +199 -0
  120. package/lib/portable-migration.mjs +104 -0
  121. package/lib/portable-onboarding-acceptance.mjs +66 -0
  122. package/lib/portable-onboarding-request.mjs +49 -0
  123. package/lib/portable-onboarding.mjs +230 -0
  124. package/lib/portable-package-preparation.mjs +188 -0
  125. package/lib/portable-policy.mjs +44 -0
  126. package/lib/portable-shape.mjs +35 -0
  127. package/lib/portable-soul.mjs +38 -0
  128. package/lib/portable-state.mjs +80 -0
  129. package/lib/portable-values.mjs +181 -0
  130. package/lib/prepare-composition.mjs +151 -0
  131. package/lib/prepared-bindings.mjs +78 -0
  132. package/lib/prepared-resources.mjs +127 -0
  133. package/lib/provider-binding-broker.mjs +59 -0
  134. package/lib/provider-binding-wire.mjs +110 -0
  135. package/lib/provider-binding.mjs +22 -0
  136. package/lib/repository-observation.mjs +226 -0
  137. package/lib/resolution-shape.mjs +393 -0
  138. package/lib/schedule-capsule.mjs +206 -0
  139. package/lib/schedule.mjs +259 -38
  140. package/lib/servers.mjs +15 -0
  141. package/lib/soul-constraints.mjs +40 -0
  142. package/lib/source-projection.mjs +84 -0
  143. package/lib/source-spec.mjs +189 -0
  144. package/lib/workspace-definition.mjs +126 -0
  145. package/lib/workspace-discovery.mjs +146 -0
  146. package/package-catalog.json +1 -1
  147. package/package.json +3 -2
  148. package/packages/record/lib/capture-cc.mjs +14 -6
  149. package/packages/record/lib/formats.mjs +14 -3
  150. package/packages/record/lib/native-history.mjs +277 -7
  151. package/packages/record/lib/session-snapshot.mjs +25 -5
  152. package/packages/record/lib/sessions-for-home.mjs +30 -13
  153. package/skills/oats/SKILL.md +12 -7
  154. package/skills/oats-config/SKILL.md +12 -9
  155. package/skills/oats-packages/SKILL.md +12 -8
  156. package/skills/oats-portable/SKILL.md +116 -0
  157. package/skills/oats-portable-artifacts/SKILL.md +63 -0
  158. package/skills/oats-portable-setup/SKILL.md +69 -0
@@ -0,0 +1,141 @@
1
+ /** Tree mechanics only. No acquisition, selection, trust, lock or lifecycle
2
+ * policy. Callers supply the verifier used during publication. */
3
+ import {
4
+ chmodSync, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync,
5
+ readdirSync, readlinkSync, renameSync, rmdirSync, rmSync, symlinkSync,
6
+ } from "node:fs";
7
+ import { dirname, join, relative } from "node:path";
8
+ import { createHash } from "node:crypto";
9
+ import { oatsError } from "./errors.mjs";
10
+
11
+ /** Recursively copy a tree the way `cpSync(..., { recursive: true })` would —
12
+ * except catchably.
13
+ *
14
+ * Node 22's recursive `cpSync` performs its recursion in native code, and on
15
+ * macOS an unreadable directory inside the tree surfaces as an uncaught libc++
16
+ * `filesystem_error` that TERMINATES THE PROCESS. No JS `catch` or `finally`
17
+ * runs, so a transaction using it can never clean up staging or roll back the
18
+ * store, the lock and the ignore file. Every package-, capability- and
19
+ * user-shaped tree in the engine therefore goes through this hand-walk instead,
20
+ * where an EACCES is an ordinary throwable error.
21
+ *
22
+ * Semantics chosen to be safe rather than maximally faithful:
23
+ * - deterministic traversal (sorted entries), so two copies of one tree hash
24
+ * identically;
25
+ * - symlinks are recreated VERBATIM — never followed, never rewritten — because
26
+ * the bytes about to be hashed must be the bytes the author wrote;
27
+ * - FIFOs, sockets and device nodes are rejected fail-closed: they are not
28
+ * distributable content, and copying them has no defined meaning here;
29
+ * - directory modes are applied AFTER their children, so a read-only source
30
+ * directory cannot block writing its own contents. */
31
+ export function copyTreeSafe(src, dest) {
32
+ const st = lstatSync(src);
33
+ if (st.isSymbolicLink()) { symlinkSync(readlinkSync(src), dest); return; }
34
+ if (st.isFile()) { copyFileSync(src, dest); chmodSync(dest, st.mode & 0o7777); return; }
35
+ if (!st.isDirectory()) {
36
+ throw oatsError("invalid-source", `${src} is not a regular file, directory or symlink (${st.isFIFO() ? "FIFO" : st.isSocket() ? "socket" : st.isBlockDevice() || st.isCharacterDevice() ? "device node" : "unsupported file type"}) — package and capability trees carry distributable content only`);
37
+ }
38
+ mkdirSync(dest, { recursive: true });
39
+ for (const e of readdirSync(src, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
40
+ copyTreeSafe(join(src, e.name), join(dest, e.name));
41
+ }
42
+ chmodSync(dest, st.mode & 0o7777);
43
+ }
44
+
45
+ /** Stable integrity of a MATERIALIZED capability artifact: every byte under
46
+ * `.agents/capabilities/installed/<id>/`, with NO exclusions — capability source,
47
+ * the materialized runtime closure (node_modules), and the generated
48
+ * `.oats-installation.json` provenance file all count. This is the only digest
49
+ * executable trust binds to, which is why a separate dependency digest does not
50
+ * exist at capability level: the closure is inside the artifact, so tampering
51
+ * with a dependency is ordinary artifact drift. */
52
+ export function capabilityArtifactIntegrity(dir) {
53
+ const hash = createHash("sha256");
54
+ const walk = (d) => {
55
+ for (const e of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
56
+ const p = join(d, e.name);
57
+ if (e.isDirectory()) walk(p);
58
+ else if (e.isFile()) { hash.update(relative(dir, p)); hash.update("\0file\0"); hash.update(readFileSync(p)); hash.update("\0"); }
59
+ else if (e.isSymbolicLink()) { hash.update(relative(dir, p)); hash.update("\0symlink\0"); hash.update(readlinkSync(p)); hash.update("\0"); }
60
+ }
61
+ };
62
+ walk(dir);
63
+ return `sha256-${hash.digest("hex")}`;
64
+ }
65
+
66
+ // ONLY for this preparer's unpublished staging, never source/destination trees.
67
+ // copyTreeSafe preserves read-only directories after copying their children; rm's
68
+ // force option does not make those directories removable. Restore owner access
69
+ // top-down, using lstat at every entry so even directory symlinks stay untouched.
70
+ // Files need no chmod to unlink them. Successful publication transfers the whole
71
+ // staging root to the destination; the caller must not clean that root afterwards.
72
+ export function removeOwnedStaging(staging) {
73
+ const makeRemovable = (path) => {
74
+ let st;
75
+ try { st = lstatSync(path); }
76
+ catch (error) { if (error.code === "ENOENT") return; throw error; }
77
+ if (!st.isDirectory()) return;
78
+ if ((st.mode & 0o700) !== 0o700) chmodSync(path, (st.mode & 0o7777) | 0o700);
79
+ for (const name of readdirSync(path)) makeRemovable(join(path, name));
80
+ };
81
+ makeRemovable(staging);
82
+ rmSync(staging, { recursive: true, force: true });
83
+ }
84
+
85
+ /** Copy into same-parent staging, verify, then publish by rename.
86
+ * The staged root is a sibling of the destination: Darwin can refuse moving a
87
+ * read-only directory between parents even when both parents are writable. A
88
+ * sibling rename preserves the copied root's mode without rewriting its `..`.
89
+ * The caller preflights the source/destination and owns existing-entry policy.
90
+ * `verify` is synchronous and must throw on invalid copied or competing bytes;
91
+ * this mechanical helper knows nothing about the kind of artifact or its lock.
92
+ * A competing nonempty tree is reused only after verification. Rename is NOT a
93
+ * general no-replace primitive: an empty destination appearing after caller
94
+ * preflight can be replaced. Normal errors remove owned staging; cleanup failure
95
+ * throws with its stagingPath, preserving the primary code/provenance and cause
96
+ * in an AggregateError when both fail. No success receipt hides failed cleanup.
97
+ * Hostile concurrent mutation, crash recovery and power-loss durability are not
98
+ * promised. */
99
+ export function publishArtifactTree(sourceDir, dir, verify) {
100
+ const staging = mkdtempSync(join(dirname(dir), ".staging-"));
101
+ const tree = staging;
102
+ let failed = false, published = false, primary;
103
+ try {
104
+ // Preserve mechanical copy semantics for file/link roots too; retention's
105
+ // verifier owns the directory/containment policy. Only remove our empty slot.
106
+ if (!lstatSync(sourceDir).isDirectory()) rmdirSync(tree);
107
+ copyTreeSafe(sourceDir, tree);
108
+ verify(tree); // source may have changed during copy
109
+ try { renameSync(tree, dir); }
110
+ catch (e) {
111
+ // Darwin may report EACCES rather than ENOTEMPTY for a read-only winner.
112
+ // Permission denial alone proves nothing: require an observed destination,
113
+ // then let the caller verify its exact bytes, shape and provenance. If it
114
+ // is absent, preserve the original denial; damage is never repaired.
115
+ if (e.code === "EACCES") {
116
+ try { lstatSync(dir); }
117
+ catch (lookup) { if (lookup.code === "ENOENT") throw e; throw lookup; }
118
+ } else if (e.code !== "EEXIST" && e.code !== "ENOTEMPTY") throw e;
119
+ verify(dir);
120
+ return "kept";
121
+ }
122
+ published = true;
123
+ return "retained";
124
+ } catch (error) {
125
+ failed = true;
126
+ primary = error;
127
+ throw error;
128
+ } finally {
129
+ try { if (!published) removeOwnedStaging(staging); }
130
+ catch (cleanup) {
131
+ const diagnostic = `artifact staging cleanup failed at ${staging}: ${cleanup?.message ?? cleanup}`;
132
+ const error = failed
133
+ ? new AggregateError([primary, cleanup], `${primary?.message ?? primary}; ${diagnostic}`, { cause: primary })
134
+ : new Error(diagnostic, { cause: cleanup });
135
+ error.code = failed ? primary?.code : cleanup?.code;
136
+ if (failed && primary?.provenance !== undefined) error.provenance = primary.provenance;
137
+ error.stagingPath = staging;
138
+ throw error;
139
+ }
140
+ }
141
+ }
@@ -0,0 +1,179 @@
1
+ /** Immutable retention of already materialized capabilities. Acquisition,
2
+ * selection, approval and instance dispatch remain the caller's responsibility.
3
+ * See docs/design/2026-09-14-artifact-retention-contract.md. */
4
+ import {
5
+ linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync,
6
+ readlinkSync, realpathSync, rmSync, writeFileSync,
7
+ } from "node:fs";
8
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
9
+ import { capabilityArtifactIntegrity, publishArtifactTree } from "./artifact-tree.mjs";
10
+ import { oatsError } from "./errors.mjs";
11
+ import {
12
+ CAPABILITIES_DIRNAME, isMaterializedCapabilityId, validateCapabilityLockEntry,
13
+ validateLockEntry, verifyCapabilityInstallation,
14
+ } from "./capability-provenance.mjs";
15
+
16
+ const INTEGRITY = /^sha256-[0-9a-f]{64}$/;
17
+ const STORE_PARTS = [...CAPABILITIES_DIRNAME.split(sep), "artifacts"];
18
+ const exists = (path) => {
19
+ try { return lstatSync(path); }
20
+ catch (e) { if (e.code === "ENOENT") return null; throw e; }
21
+ };
22
+ const outside = (root, path) => {
23
+ const rel = relative(root, path);
24
+ return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
25
+ };
26
+
27
+ /** The complete digest, not a short display prefix, identifies a revision. */
28
+ export function retainedCapabilityDir(scope, capability, integrity) {
29
+ if (!isMaterializedCapabilityId(capability) || typeof integrity !== "string" || !INTEGRITY.test(integrity)) {
30
+ throw oatsError("invalid-artifact-reference", "retained capability requires a valid capability ID and full sha256 integrity");
31
+ }
32
+ return join(resolve(scope), CAPABILITIES_DIRNAME, "artifacts", capability, integrity);
33
+ }
34
+
35
+ function lockRows(capability, lock) {
36
+ if (!lock?.capabilities || !Object.hasOwn(lock.capabilities, capability) || !lock.packages) {
37
+ throw oatsError("invalid-lock", `no captured capability/package lock for ${capability}`);
38
+ }
39
+ for (const [id, row] of Object.entries(lock.packages)) validateLockEntry(id, row, lock.packages);
40
+ const row = lock.capabilities[capability];
41
+ validateCapabilityLockEntry(capability, row, lock.packages);
42
+ return { row, pkg: lock.packages[row.package] };
43
+ }
44
+
45
+ // A retained tree must remain usable after its source disappears. In particular,
46
+ // an absolute symlink pointing back into today's source is not a portable link.
47
+ export function assertRetainableTree(root) {
48
+ const st = exists(root);
49
+ if (!st) throw oatsError("artifact-not-found", `artifact tree is absent: ${root}`);
50
+ if (!st.isDirectory()) throw oatsError("invalid-artifact", `artifact root must be a directory: ${root}`);
51
+ const boundary = realpathSync(root);
52
+ const walk = (dir) => {
53
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
54
+ const path = join(dir, e.name);
55
+ if (e.isDirectory()) walk(path);
56
+ else if (e.isSymbolicLink()) {
57
+ let target;
58
+ try { target = realpathSync(path); }
59
+ catch { throw oatsError("artifact-not-contained", `broken artifact symlink: ${path}`); }
60
+ if (isAbsolute(readlinkSync(path)) || outside(boundary, target)) {
61
+ throw oatsError("artifact-not-contained", `artifact symlink must stay relative and inside its retained tree: ${path}`);
62
+ }
63
+ } else if (!e.isFile()) {
64
+ throw oatsError("invalid-artifact", `artifact contains an unsupported file type: ${path}`);
65
+ }
66
+ }
67
+ };
68
+ walk(root);
69
+ }
70
+
71
+ function verifyTree(dir, capability, row, pkg) {
72
+ assertRetainableTree(dir);
73
+ const actual = capabilityArtifactIntegrity(dir);
74
+ if (actual !== row.integrity) {
75
+ throw oatsError("integrity-drift", `retained capability ${capability}: expected ${row.integrity}, got ${actual} at ${dir}`);
76
+ }
77
+ verifyCapabilityInstallation(dir, capability, row, pkg);
78
+ }
79
+
80
+ export function ensureStoreIgnore(dir) {
81
+ const ignore = join(dir, ".gitignore");
82
+ const verify = () => {
83
+ if (!exists(ignore)?.isFile() || readFileSync(ignore, "utf8") !== "*\n") {
84
+ throw oatsError("invalid-artifact-store", `managed artifact ignore file must contain exactly '*': ${ignore}`);
85
+ }
86
+ };
87
+ if (exists(ignore)) { verify(); return; }
88
+
89
+ // Exclusive creation at the public name exposes an empty/partial file. Write
90
+ // and close in an owned, same-filesystem directory, then link atomically with
91
+ // no replacement. EEXIST is evidence to verify, never permission to repair.
92
+ // No retries, hostile-host isolation or power-loss durability are promised;
93
+ // a crash can leave dot-prefixed metadata staging for explicit cleanup.
94
+ const staging = mkdtempSync(join(dir, ".gitignore-"));
95
+ let failed = false, primary;
96
+ try {
97
+ const temp = join(staging, ".gitignore");
98
+ writeFileSync(temp, "*\n", { flag: "wx" });
99
+ try { linkSync(temp, ignore); }
100
+ catch (e) { if (e.code !== "EEXIST") throw e; }
101
+ verify();
102
+ } catch (error) {
103
+ failed = true;
104
+ primary = error;
105
+ throw error;
106
+ } finally {
107
+ // Only our metadata staging. Never unlink the public name (even on error),
108
+ // traverse another producer's staging, or change source/published modes.
109
+ try { rmSync(staging, { recursive: true, force: true }); }
110
+ catch (cleanup) {
111
+ const diagnostic = `artifact ignore staging cleanup failed at ${staging}: ${cleanup?.message ?? cleanup}`;
112
+ const error = failed
113
+ ? new AggregateError([primary, cleanup], `${primary?.message ?? primary}; ${diagnostic}`, { cause: primary })
114
+ : new Error(diagnostic, { cause: cleanup });
115
+ error.code = failed ? primary?.code : cleanup?.code;
116
+ if (failed && primary?.provenance !== undefined) error.provenance = primary.provenance;
117
+ error.stagingPath = staging;
118
+ throw error;
119
+ }
120
+ }
121
+ }
122
+
123
+ // The scope itself may be a user's symlink. Managed descendants must be real
124
+ // directories, so a stale/broken store link cannot redirect publication.
125
+ function storePath(scope, capability, create) {
126
+ let dir;
127
+ try { dir = realpathSync(scope); }
128
+ catch (e) {
129
+ if (e.code === "ENOENT") throw oatsError("artifact-not-found", `artifact scope is absent: ${scope}`);
130
+ throw e;
131
+ }
132
+ for (const [index, part] of [...STORE_PARTS, capability].entries()) {
133
+ dir = join(dir, part);
134
+ if (create) {
135
+ try { mkdirSync(dir); }
136
+ catch (e) { if (e.code !== "EEXIST") throw e; }
137
+ }
138
+ const st = exists(dir);
139
+ if (!st && !create) throw oatsError("artifact-not-found", `artifact store path is absent: ${dir}`);
140
+ if (!st || !st.isDirectory()) throw oatsError("invalid-artifact-store", `artifact store component must be a directory: ${dir}`);
141
+ if (index === STORE_PARTS.length - 1 && create) {
142
+ // Owned store metadata, outside every hashed artifact. Write before any
143
+ // staging payload; no Git command or change to authored directories.
144
+ ensureStoreIgnore(dir);
145
+ }
146
+ }
147
+ return dir;
148
+ }
149
+
150
+ /** Verify an exact retained revision using captured provenance. No current lock,
151
+ * source repository, trust approval or network lookup participates. */
152
+ export function verifyRetainedCapability(scope, capability, lock) {
153
+ const { row, pkg } = lockRows(capability, lock);
154
+ retainedCapabilityDir(scope, capability, row.integrity); // validate before path use
155
+ const dir = join(storePath(scope, capability, false), row.integrity);
156
+ verifyTree(dir, capability, row, pkg);
157
+ return { capability, integrity: row.integrity, dir };
158
+ }
159
+
160
+ /** Publish once by same-filesystem rename. Never repairs or replaces a retained
161
+ * revision; a damaged existing entry requires explicit operator intervention.
162
+ * `lock` carries validated package/capability provenance from one resolution.
163
+ * Its trust flags are not grants from this API, and are never written here. */
164
+ export function retainCapabilityArtifact(scope, sourceDir, capability, lock) {
165
+ const { row, pkg } = lockRows(capability, lock);
166
+ retainedCapabilityDir(scope, capability, row.integrity);
167
+ verifyTree(sourceDir, capability, row, pkg);
168
+ const root = storePath(scope, capability, true);
169
+ const dir = join(root, row.integrity);
170
+ const receipt = { capability, integrity: row.integrity, dir };
171
+ if (exists(dir)) {
172
+ verifyTree(dir, capability, row, pkg);
173
+ return { ...receipt, status: "kept" };
174
+ }
175
+ // Refuse recursive copies if a caller supplied an ancestor of the store.
176
+ if (!outside(realpathSync(sourceDir), root)) throw oatsError("invalid-artifact", "artifact source contains its destination store");
177
+ const status = publishArtifactTree(sourceDir, dir, (tree) => verifyTree(tree, capability, row, pkg));
178
+ return { ...receipt, status };
179
+ }
@@ -0,0 +1,15 @@
1
+ /** The single existing executable-surface definition, shared with captured
2
+ * approval diagnostics. This projection does not validate/compile a manifest
3
+ * or permit execution; the action adapter must validate the full contract. */
4
+ export function executableSurfaceOf(manifest) {
5
+ return {
6
+ commands: Object.keys(manifest?.commands || {}),
7
+ hooks: Object.keys(manifest?.hooks || {}),
8
+ environment: [...(manifest?.environment || [])],
9
+ ...(manifest?.environmentNamespaces?.length ? { environmentNamespaces: [...manifest.environmentNamespaces] } : {}),
10
+ };
11
+ }
12
+ export function hasExecutableSurface(manifest) {
13
+ const s = executableSurfaceOf(manifest);
14
+ return s.commands.length > 0 || s.hooks.length > 0 || s.environment.length > 0;
15
+ }
@@ -0,0 +1,39 @@
1
+ /** Capability-owned helper instruction policy and selected supplemental inputs.
2
+ * Shape only: no source lookup, binding, readiness, side effects or fallback. */
3
+ import { canonicalJson } from './portable-values.mjs';
4
+ import { objectAt, invalidShape } from './portable-shape.mjs';
5
+ import { portablePath } from './source-spec.mjs';
6
+
7
+ export function validateHelperInjection(value) {
8
+ canonicalJson(value);
9
+ const file = value?.mode === 'file';
10
+ objectAt(value, file ? ['version', 'mode', 'path'] : ['version', 'mode'], file ? ['version', 'mode', 'path'] : ['version', 'mode']);
11
+ if (value.version !== 1 || !['inherit', 'omit', 'file'].includes(value.mode)) invalidShape('/helperInjection', 'unsupported helper instruction policy');
12
+ if (file) portablePath(value.path);
13
+ return value;
14
+ }
15
+
16
+ export function validateHookInputs(value) {
17
+ canonicalJson(value);
18
+ objectAt(value, ['sourceReceipt'], [], '/inputs');
19
+ if (Object.hasOwn(value, 'sourceReceipt')) {
20
+ objectAt(value.sourceReceipt, ['version'], ['version'], '/inputs/sourceReceipt');
21
+ if (value.sourceReceipt.version !== 1) invalidShape('/inputs/sourceReceipt/version', 'unsupported source receipt input version');
22
+ }
23
+ return value;
24
+ }
25
+
26
+ /** Presence is explicit. In particular, absent/empty inputs are NOT consent;
27
+ * this validator does not infer whether a provider depends on that input. */
28
+ export function validateCapabilityInputDeclarations(manifest) {
29
+ canonicalJson(manifest);
30
+ objectAt(manifest, null, []);
31
+ if (Object.hasOwn(manifest, 'helperInjection')) validateHelperInjection(manifest.helperInjection);
32
+ if (Object.hasOwn(manifest, 'hooks')) {
33
+ objectAt(manifest.hooks, null, [], '/hooks');
34
+ for (const hook of Object.values(manifest.hooks)) {
35
+ if (hook !== null && typeof hook === 'object' && Object.hasOwn(hook, 'inputs')) validateHookInputs(hook.inputs);
36
+ }
37
+ }
38
+ return manifest;
39
+ }
@@ -0,0 +1,231 @@
1
+ /** Existing materialized-capability data contract: layout/identity, lock-row
2
+ * validation and installation provenance. No scope discovery, lock writes,
3
+ * acquisition, approval, resolver or lifecycle dispatch. */
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { isAbsolute, join } from "node:path";
6
+ import { oatsError } from "./errors.mjs";
7
+
8
+ /** Scope-relative capability store subtrees. */
9
+ export const CAPABILITIES_DIRNAME = join(".agents", "capabilities");
10
+ export const INSTALLED_SUBDIR = "installed";
11
+
12
+ /** THE identity grammar for a MATERIALIZED (revised-v2) capability.
13
+ *
14
+ * A materialized capability id is not merely a label: it becomes a DIRECTORY
15
+ * NAME directly under `installed/`, so anything that can steer a filesystem
16
+ * join must be impossible before the join happens. The grammar is deliberately
17
+ * the package-id grammar — namespaced dots are fine, and `/`, `\`, `..`,
18
+ * absolute forms, `@`, and percent-encoded spellings are all outside it.
19
+ *
20
+ * The LEGACY v1 / owned / `from: path:` grammar (loadManifestAt) stays looser
21
+ * on purpose: those artifacts are named by `basename()` of their source, never
22
+ * by the declared id, so the id never reaches a path there. Tightening it would
23
+ * strand already-published standalone capabilities. */
24
+ export const CAPABILITY_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
25
+ export const isMaterializedCapabilityId = (id) => typeof id === "string" && CAPABILITY_ID_RE.test(id);
26
+ /** Why a capability id was refused, in one sentence, for every caller's own
27
+ * typed error (the lock parser raises invalid-lock, manifest validation raises
28
+ * invalid-package-manifest — the code each consumer already branches on). */
29
+ export const capabilityIdViolation = (id) =>
30
+ `${JSON.stringify(id)} is not a valid capability identity — expected ${CAPABILITY_ID_RE.source} (a namespaced id such as "oats.okf"; path separators, "..", absolute paths, "@" and encoded forms are refused because the id names a directory under ${INSTALLED_SUBDIR}/)`;
31
+
32
+ /** Generated provenance file inside every materialized artifact. It is INSIDE
33
+ * the hashed tree, so tampering with it is integrity drift; the lock stays
34
+ * authoritative. */
35
+ export const CAPABILITY_INSTALLATION_FILE = ".oats-installation.json";
36
+
37
+ export const PACKAGE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
38
+ /** Own-property PRESENCE of any of these on a package row is forbidden
39
+ * transitional evidence (contract §4.1) — never truthiness and never array
40
+ * length, so an empty `capabilities: []` or a dependency-free old row still
41
+ * classifies. Package-row `path`/`dependencies` are NEVER tells: the current
42
+ * shape retains both. */
43
+ export const TRANSITIONAL_ROW_FIELDS = ["capabilities", "trustedCapabilities", "depsIntegrity"];
44
+
45
+ /** Normalize a configured package path to its canonical form, or throw.
46
+ *
47
+ * Canonical form is a POSIX-relative path with no redundant or trailing
48
+ * separators; every spelling of the repository root ("", ".", "./", "./.")
49
+ * normalizes to the single canonical "." so a root selection round-trips
50
+ * identically through spec → lock → JSON → doctor/list/update (contract §4).
51
+ *
52
+ * Fail-closed: absolute paths, Windows drive paths, host-ambient "~" spellings,
53
+ * backslash separators (ambiguous — a backslash is a legal POSIX filename
54
+ * character, so accepting it as a separator would make containment checks
55
+ * disagree with the filesystem) and NUL are rejected as invalid-source; ".."
56
+ * traversal is path-escape. Returns undefined ONLY for an absent value, so the
57
+ * caller can apply the source-appropriate default. */
58
+ export function normalizePackagePath(raw, { where = "package path", code = "invalid-source" } = {}) {
59
+ // ABSENT means absent. A present `null` (JSON's way of spelling a malformed
60
+ // value) is a violation, not a fall-through to the caller's default — a
61
+ // catalog entry that says `"path": null` must fail, not silently install
62
+ // DEFAULT_PACKAGE_PATH.
63
+ if (raw === undefined) return undefined;
64
+ if (typeof raw !== "string") throw oatsError(code, `${where} must be a string (got ${Array.isArray(raw) ? "array" : raw === null ? "null" : typeof raw})`);
65
+ const s = raw.trim();
66
+ if (s.includes("\0")) throw oatsError(code, `${where} contains a NUL byte`);
67
+ if (s.startsWith("~")) throw oatsError(code, `${where} "${s}" is a host-ambient path — package paths are repository-relative`);
68
+ if (s.includes("\\")) throw oatsError(code, `${where} "${s}" uses backslashes — package paths are POSIX-relative (use "/")`);
69
+ if (/^[A-Za-z]:[/\\]/.test(s)) throw oatsError(code, `${where} "${s}" is an absolute drive path — package paths are repository-relative`);
70
+ if (isAbsolute(s)) throw oatsError(code, `${where} "${s}" is absolute — package paths are repository-relative`);
71
+ const segments = s.split("/").filter((seg) => seg !== "" && seg !== ".");
72
+ if (segments.includes("..")) throw oatsError("path-escape", `${where} "${s}" escapes the source root with ".."`);
73
+ return segments.length ? segments.join("/") : ".";
74
+ }
75
+
76
+ /** Parse a lock entry's `source` against the EXACT normalized grammar the
77
+ * writer produces. Strict on purpose: `updatePackage` turns this back into a
78
+ * source spec, so a payload that merely "starts with catalog:" but is not a
79
+ * valid catalog id gets RECLASSIFIED downstream — `catalog:../evil` would be
80
+ * re-parsed as a host-relative local path and acquired from the operator's
81
+ * filesystem. A lock also never carries a `#<path>` fragment: the selected
82
+ * root is the entry's own `path` field, and a fragment here would produce a
83
+ * double-fragment spec on update. */
84
+ export function parseLockSource(src) {
85
+ const s = String(src || "");
86
+ const bad = (why) => oatsError("invalid-source", `unknown lock source "${src}" — ${why}`);
87
+ if (s.includes("#")) throw bad(`lock sources carry no "#<path>" fragment; the selected package root is the entry's "path" field`);
88
+ if (s.startsWith("path:")) {
89
+ const p = s.slice(5);
90
+ if (!p) throw bad("empty path source");
91
+ if (!isAbsolute(p)) throw bad("path source must be an absolute directory (the writer always resolves it)");
92
+ return { kind: "path", path: p, normalized: s };
93
+ }
94
+ if (s.startsWith("catalog:")) {
95
+ const body = s.slice(8);
96
+ // Split at the FIRST "@", mirroring the public parser's regex: the catalog
97
+ // id grammar cannot contain "@", so everything after the first one is the
98
+ // selector. Splitting at the LAST "@" misreads a legitimate ref spelling
99
+ // such as `oats.okf@release@candidate` — which the writer does produce —
100
+ // as the id `oats.okf@release`.
101
+ const at = body.indexOf("@");
102
+ const id = at > 0 ? body.slice(0, at) : body;
103
+ const selector = at > 0 ? body.slice(at + 1) : undefined;
104
+ if (!PACKAGE_ID_RE.test(id)) throw bad(`"${id}" is not a valid official catalog id`);
105
+ if (at > 0 && !selector) throw bad("empty catalog selector");
106
+ return { kind: "catalog", id, selector, normalized: s };
107
+ }
108
+ if (s.startsWith("git:")) {
109
+ const body = s.slice(4);
110
+ const at = body.lastIndexOf("@") > body.lastIndexOf("/") ? body.lastIndexOf("@") : -1;
111
+ const url = at > 0 ? body.slice(0, at) : body;
112
+ const ref = at > 0 ? body.slice(at + 1) : undefined;
113
+ if (!url) throw bad("empty git url");
114
+ if (at > 0 && !ref) throw bad("empty git ref");
115
+ if (!/^(https?:\/\/|file:\/\/|git@|ssh:\/\/|git:\/\/)/.test(url)) throw bad(`"${url}" is not an http(s)/ssh/file/git URL`);
116
+ return { kind: "git", url, ref, normalized: s };
117
+ }
118
+ throw oatsError("invalid-source", `unknown lock source "${src}"`);
119
+ }
120
+
121
+ /** Semantic lock-entry validation for a PACKAGE row (runtime API addendum §4):
122
+ * source/commit pairing, canonical path, dependency references (incl. self and
123
+ * cycle over the locked graph), digest shapes, uniqueness. Run BEFORE restore,
124
+ * trust/approval, update/remove/migration planning, the locked-template reader,
125
+ * and doctor/list consumption. Fails closed with code "invalid-lock" carrying
126
+ * file/package provenance; never normalizes or auto-repairs on read. */
127
+ export function validateLockEntry(packageId, entry, allPackages = {}, opts = {}) {
128
+ const where = opts.file ? ` (${opts.file})` : "";
129
+ const bad = (msg) => oatsError("invalid-lock", `lock entry for package "${packageId}"${where} is invalid: ${msg}`, [{ package: packageId, file: opts.file, violation: msg }]);
130
+ if (!entry || typeof entry !== "object") throw bad("not an object");
131
+ for (const k of ["source", "path", "version", "commit", "integrity"]) if (!entry[k] || typeof entry[k] !== "string") throw bad(`missing ${k}`);
132
+ // The selected package root is a STRICT separate field (contract §1.1) stored
133
+ // in canonical form only — a lock is never normalized or repaired on read, so
134
+ // a non-canonical spelling ("./sub", "sub/", "") is invalid, not silently
135
+ // accepted. That is what makes the root representation round-trip.
136
+ {
137
+ let canonical;
138
+ try { canonical = normalizePackagePath(entry.path, { where: "path", code: "invalid-lock" }); }
139
+ catch (e) { throw bad(`invalid path ${JSON.stringify(entry.path)} — ${e.message}`); }
140
+ if (canonical !== entry.path) throw bad(`path ${JSON.stringify(entry.path)} is not in canonical form (expected ${JSON.stringify(canonical)})`);
141
+ }
142
+ if (!/^sha256-[0-9a-f]{64}$/.test(entry.integrity)) throw bad(`malformed integrity "${entry.integrity}"`);
143
+ // Present-but-wrong-typed optional fields are invalid — default ONLY when absent.
144
+ // `dependencies` is ALWAYS recorded (empty array when none), so a reader never
145
+ // has to distinguish absent from empty.
146
+ if (!Array.isArray(entry.dependencies)) throw bad("dependencies must be an array (empty when the package has none)");
147
+ for (const d of entry.dependencies) if (typeof d !== "string" || !PACKAGE_ID_RE.test(d)) throw bad(`dependencies contains an invalid package id ${JSON.stringify(d)}`);
148
+ if (new Set(entry.dependencies).size !== entry.dependencies.length) throw bad("dependencies contains duplicates");
149
+ // Package rows lock transport only. A capability list, a trust list or a
150
+ // dependency-closure digest here is the unsupported transitional shape — the
151
+ // central parser rejects those documents outright (contract §4.1); this is
152
+ // the entry-level backstop for a row reaching validation another way.
153
+ for (const gone of TRANSITIONAL_ROW_FIELDS) {
154
+ if (Object.hasOwn(entry, gone)) throw bad(`"${gone}" is a transitional package-root field — package rows lock transport only (capabilities and trust live on capability rows)`);
155
+ }
156
+ let src;
157
+ try { src = parseLockSource(entry.source); } catch { throw bad(`unrecognized source "${entry.source}"`); }
158
+ if (src.kind === "path" && !src.path) throw bad("empty path source");
159
+ if (src.kind === "git" && !src.url) throw bad("empty git source");
160
+ if (src.kind === "catalog" && !src.id) throw bad("empty catalog source");
161
+ if (src.kind === "path") {
162
+ if (entry.commit !== "local") throw bad(`path source requires commit "local", got "${entry.commit}"`);
163
+ // Local acquisition is exact-directory: the source string already names the
164
+ // package root, so the only valid contained path is the root itself.
165
+ if (entry.path !== ".") throw bad(`path source requires path "." (local sources are exact directories), got ${JSON.stringify(entry.path)}`);
166
+ }
167
+ else if (!/^[0-9a-f]{40}$/.test(entry.commit)) throw bad(`${src.kind} source requires an exact 40-hex commit, got "${entry.commit}"`);
168
+ for (const d of entry.dependencies || []) {
169
+ if (d === packageId) throw bad(`self-dependency "${d}"`);
170
+ // Object.hasOwn: a dependency literally named "constructor"/"__proto__"
171
+ // must not pass via Object.prototype.
172
+ if (!Object.hasOwn(allPackages, d)) throw bad(`dependency "${d}" is not locked in the same packages map`);
173
+ }
174
+ // Cycle over the locked dependency graph reachable from this entry.
175
+ const visiting = new Set();
176
+ const visited = new Set();
177
+ const walk = (id, chain) => {
178
+ if (visited.has(id)) return;
179
+ if (visiting.has(id)) throw bad(`dependency cycle in the locked graph: ${[...chain, id].join(" → ")}`);
180
+ visiting.add(id);
181
+ const deps = Object.hasOwn(allPackages, id) && Array.isArray(allPackages[id]?.dependencies) ? allPackages[id].dependencies : [];
182
+ for (const d of deps) if (Object.hasOwn(allPackages, d) || d === packageId) walk(d, [...chain, id]);
183
+ visiting.delete(id); visited.add(id);
184
+ };
185
+ walk(packageId, []);
186
+ return true;
187
+ }
188
+
189
+ /** Semantic validation of one CAPABILITY row against the whole document
190
+ * (contract §4). The `package` back-reference must name a locked package: it is
191
+ * the single provider truth, so a dangling reference would leave a materialized
192
+ * artifact with no provenance to restore or verify it from. */
193
+ export function validateCapabilityLockEntry(capabilityId, entry, allPackages = {}, opts = {}) {
194
+ const where = opts.file ? ` (${opts.file})` : "";
195
+ const bad = (msg) => oatsError("invalid-lock", `capability lock entry for "${capabilityId}"${where} is invalid: ${msg}`, [{ package: capabilityId, file: opts.file, violation: msg }]);
196
+ if (typeof capabilityId !== "string" || !capabilityId) throw bad("empty capability id");
197
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw bad("not an object");
198
+ for (const k of ["version", "package", "path", "integrity"]) if (!entry[k] || typeof entry[k] !== "string") throw bad(`missing ${k}`);
199
+ if (!PACKAGE_ID_RE.test(entry.package)) throw bad(`provider package ${JSON.stringify(entry.package)} is not a valid package identity`);
200
+ if (!Object.hasOwn(allPackages, entry.package)) throw bad(`provider package "${entry.package}" is not locked in the same packages map`);
201
+ {
202
+ let canonical;
203
+ try { canonical = normalizePackagePath(entry.path, { where: "path", code: "invalid-lock" }); }
204
+ catch (e) { throw bad(`invalid path ${JSON.stringify(entry.path)} — ${e.message}`); }
205
+ if (canonical !== entry.path) throw bad(`path ${JSON.stringify(entry.path)} is not in canonical form (expected ${JSON.stringify(canonical)})`);
206
+ }
207
+ if (!/^sha256-[0-9a-f]{64}$/.test(entry.integrity)) throw bad(`malformed integrity "${entry.integrity}"`);
208
+ if (typeof entry.trusted !== "boolean") throw bad(`"trusted" must be a boolean (got ${JSON.stringify(entry.trusted)})`);
209
+ return true;
210
+ }
211
+
212
+ /** Read an artifact's provenance and check it AGREES with the lock rows it was
213
+ * projected from. Disagreement is invalid-lock: the artifact and the lock claim
214
+ * different origins, and neither may silently win. (A modified file also fails
215
+ * integrity, but this gives the precise diagnosis.) */
216
+ export function verifyCapabilityInstallation(dir, capabilityId, capRow, pkgRow) {
217
+ const file = join(dir, CAPABILITY_INSTALLATION_FILE);
218
+ if (!existsSync(file)) throw oatsError("invalid-lock", `materialized capability ${capabilityId} has no ${CAPABILITY_INSTALLATION_FILE} provenance — reproject it with \`oats install\``, [{ package: capabilityId, file }]);
219
+ let doc;
220
+ try { doc = JSON.parse(readFileSync(file, "utf8")); }
221
+ catch (e) { throw oatsError("invalid-lock", `materialized capability ${capabilityId} has malformed ${CAPABILITY_INSTALLATION_FILE}: ${e.message}`, [{ package: capabilityId, file }]); }
222
+ const expected = {
223
+ schemaVersion: 1, capability: capabilityId, version: capRow.version, package: capRow.package,
224
+ packageVersion: pkgRow.version, source: pkgRow.source, commit: pkgRow.commit,
225
+ packagePath: pkgRow.path, capabilityPath: capRow.path,
226
+ };
227
+ for (const [k, want] of Object.entries(expected)) {
228
+ if (doc?.[k] !== want) throw oatsError("invalid-lock", `materialized capability ${capabilityId}: ${CAPABILITY_INSTALLATION_FILE} "${k}" is ${JSON.stringify(doc?.[k])} but the lock records ${JSON.stringify(want)}`, [{ package: capabilityId, file, violation: k }]);
229
+ }
230
+ return doc;
231
+ }
@@ -0,0 +1,21 @@
1
+ /** The existing captured loader action wire, shared with provider invocation
2
+ * validation. This is a data codec, not a dispatch table or provider policy. */
3
+ import { canonicalJson } from "./portable-values.mjs";
4
+ import { objectAt, stringAt } from "./portable-shape.mjs";
5
+ import { oatsError } from "./errors.mjs";
6
+
7
+ export function validateCapturedAction(action) {
8
+ canonicalJson(action);
9
+ if (!["inspect", "compose", "command", "operation", "hook"].includes(action?.kind)) throw oatsError("unsupported-action", "captured action is not supported by this loader");
10
+ if (["inspect", "compose"].includes(action.kind)) objectAt(action, ["kind"], ["kind"]);
11
+ else if (action.kind === "command") {
12
+ objectAt(action, ["kind", "capability", "namespace", "name"], ["kind", "name"]);
13
+ if ((action.capability === undefined) === (action.namespace === undefined)) throw oatsError("invalid-declaration", "command needs exactly one capability or namespace");
14
+ } else {
15
+ const fields = action.kind === "hook" ? ["kind", "capability", "name"] : ["kind", "slot", "name"];
16
+ objectAt(action, fields, fields);
17
+ }
18
+ for (const key of ["name", "namespace", "capability", "slot"]) if (action[key] !== undefined) stringAt(action[key], `/action/${key}`);
19
+ if (action.kind === "operation" && !["knowledge", "messaging", "tasks"].includes(action.slot)) throw oatsError("invalid-declaration", "captured operation slot is invalid");
20
+ return action;
21
+ }
@@ -0,0 +1,20 @@
1
+ /** Generic incarnation/admitted-intent identities. Composition references are
2
+ * deliberately not accepted as incarnation or logical-request identities. */
3
+ import { canonicalJson } from "./portable-values.mjs";
4
+ import { objectAt, stringAt } from "./portable-shape.mjs";
5
+ import { oatsError } from "./errors.mjs";
6
+
7
+ export function validateIncarnationId(value) {
8
+ stringAt(value, "/incarnationId", { pattern: /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/ });
9
+ return value;
10
+ }
11
+
12
+ export function validateIntentRef(value) {
13
+ canonicalJson(value, { maxBytes: 4096, maxDepth: 4, maxEntries: 16 });
14
+ objectAt(value, ["schemaVersion", "executionId", "incarnationId", "attempt"], ["schemaVersion", "executionId", "incarnationId", "attempt"]);
15
+ if (value.schemaVersion !== 1) throw oatsError("unsupported-wire-version", "unsupported captured intent version");
16
+ stringAt(value.executionId, "/executionId", { pattern: /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/ });
17
+ validateIncarnationId(value.incarnationId);
18
+ if (!Number.isSafeInteger(value.attempt) || value.attempt < 1) throw oatsError("invalid-declaration", "intent attempt must be a positive integer");
19
+ return value;
20
+ }