@awebai/oats 0.25.8 → 0.26.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 (185) hide show
  1. package/README.md +8 -6
  2. package/bin/oats.mjs +576 -1714
  3. package/capabilities/oats-authoring/oats-package.json +2 -2
  4. package/capabilities/oats-authoring/oats.json +2 -2
  5. package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +46 -25
  6. package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +13 -6
  7. package/capabilities/oats-aweb/bin/oats-aweb.mjs +334 -72
  8. package/capabilities/oats-aweb/injects/aweb.md +7 -2
  9. package/capabilities/oats-aweb/lib/binding-wire.mjs +107 -5
  10. package/capabilities/oats-aweb/lib/captured-native.mjs +1 -1
  11. package/capabilities/oats-aweb/lib/grant-custody.mjs +38 -0
  12. package/capabilities/oats-aweb/oats.json +14 -4
  13. package/capabilities/oats-jira/bin/oats-jira.mjs +4 -4
  14. package/capabilities/oats-jira/oats.json +2 -2
  15. package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +6 -3
  16. package/capabilities/oats-linear/bin/oats-linear-hook.mjs +6 -4
  17. package/capabilities/oats-linear/oats.json +2 -2
  18. package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +6 -0
  19. package/capabilities/oats-okf/bin/oats-okf.mjs +1 -1
  20. package/capabilities/oats-okf/lib/binding-wire.mjs +1 -1
  21. package/capabilities/oats-okf/lib/migration.mjs +2 -2
  22. package/capabilities/oats-okf/lib/sources.mjs +5 -4
  23. package/capabilities/oats-okf/lib/stores.mjs +40 -9
  24. package/capabilities/oats-okf/lib/worker.mjs +3 -3
  25. package/capabilities/oats-okf/oats.json +1 -1
  26. package/capabilities/oats-review/oats.json +3 -2
  27. package/docs/capabilities.md +218 -47
  28. package/docs/capability-manifest.schema.json +13 -4
  29. package/docs/configuration.md +17 -5
  30. package/docs/conventions.md +16 -26
  31. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +1 -1
  32. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +3 -3
  33. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +2 -2
  34. package/docs/design/2026-09-15-portable-souls-handoff.md +2 -2
  35. package/docs/design/2026-09-15-portable-souls-implementation.md +1 -1
  36. package/docs/design/2026-09-20-redesign-program-board.md +2 -2
  37. package/docs/design/2026-09-23-workspace-module-contracts.md +1 -1
  38. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +1 -1
  39. package/docs/design/2026-09-24-desktop-phase-f-boundary.md +54 -10
  40. package/docs/design/2026-09-24-phase-d-plan.md +77 -0
  41. package/docs/design/2026-09-25-teams-contract.md +226 -0
  42. package/docs/design/README.md +3 -3
  43. package/docs/design/launch-configurations.md +20 -16
  44. package/docs/design/operations-contract.md +27 -10
  45. package/docs/desktop-cli-api.md +546 -264
  46. package/docs/desktop-instance-start.md +1 -1
  47. package/docs/desktop.md +7 -13
  48. package/docs/execution-targets.md +16 -18
  49. package/docs/first-team.md +14 -17
  50. package/docs/implementation.md +28 -59
  51. package/docs/integrations.md +88 -32
  52. package/docs/knowledge-capability-authoring.md +1 -1
  53. package/docs/knowledge-reference/package-craft.md +10 -8
  54. package/docs/knowledge-theory.md +1 -1
  55. package/docs/knowledge.md +10 -11
  56. package/docs/layers.md +16 -17
  57. package/docs/oats-local.schema.json +29 -1
  58. package/docs/oats-membership.schema.json +5 -3
  59. package/docs/oats-package.schema.json +2 -2
  60. package/docs/oats-workspace.schema.json +1 -1
  61. package/docs/{official-marketplace.md → official-catalog.md} +15 -16
  62. package/docs/packages.md +75 -52
  63. package/docs/release-notes/v0.22.0.md +1 -1
  64. package/docs/release-notes/v0.23.1.md +1 -1
  65. package/docs/release-notes/v0.25.9.md +23 -0
  66. package/docs/release-notes/v0.26.0.md +670 -0
  67. package/docs/schedules.md +48 -126
  68. package/docs/soul.schema.json +11 -4
  69. package/docs/souls-and-instances.md +56 -43
  70. package/docs/workspaces.md +80 -58
  71. package/injects/instance-boundary.md +1 -1
  72. package/injects/work-attached.md +1 -1
  73. package/injects/work-workspace.md +2 -2
  74. package/lib/{portable-files.mjs → bounded-read.mjs} +6 -6
  75. package/lib/{portable-values.mjs → canonical-json.mjs} +3 -12
  76. package/lib/capability-contract.mjs +110 -0
  77. package/lib/config-data.mjs +2 -2
  78. package/lib/core.mjs +700 -4824
  79. package/lib/digest.mjs +12 -0
  80. package/lib/instance-inspect.mjs +396 -0
  81. package/lib/instance-lifecycle.mjs +3 -4
  82. package/lib/instance-resolution.mjs +212 -26
  83. package/lib/instruction-composition.mjs +0 -20
  84. package/lib/materialize.mjs +6 -4
  85. package/lib/operator-dispatch.mjs +33 -13
  86. package/lib/packages.mjs +25 -190
  87. package/lib/provider-binding.mjs +4 -2
  88. package/lib/provider-reasons.mjs +3 -68
  89. package/lib/resolve.mjs +204 -68
  90. package/lib/schedule.mjs +97 -272
  91. package/lib/servers.mjs +13 -13
  92. package/lib/{portable-shape.mjs → shape.mjs} +4 -3
  93. package/lib/tree-copy.mjs +44 -0
  94. package/lib/workspace.mjs +125 -20
  95. package/package-catalog.json +7 -7
  96. package/package.json +1 -1
  97. package/skills/integration-authoring/SKILL.md +48 -40
  98. package/skills/oats-getting-started/SKILL.md +105 -110
  99. package/skills/oats-support/SKILL.md +2 -2
  100. package/skills/soul-craft/SKILL.md +13 -6
  101. package/bin/oats-pi-sdk-host.mjs +0 -17
  102. package/docs/2026-09-03-architecture-proposal.md +0 -642
  103. package/docs/artifact-approvals.schema.json +0 -7
  104. package/docs/captured-invocation-context.schema.json +0 -7
  105. package/docs/captured-resolution.schema.json +0 -7
  106. package/docs/design/package-engine-contract.md +0 -813
  107. package/docs/design/package-runtime-api.md +0 -588
  108. package/docs/desktop-succession.md +0 -57
  109. package/docs/execution-capsule.schema.json +0 -108
  110. package/docs/first-team-demo.md +0 -92
  111. package/docs/knowledge-migration.md +0 -147
  112. package/docs/migration-from-oas.md +0 -103
  113. package/docs/oats-config.schema.json +0 -172
  114. package/docs/oats-lock-v3.schema.json +0 -7
  115. package/docs/oats-lock.schema.json +0 -175
  116. package/docs/operating-team-migration.md +0 -470
  117. package/docs/portable.schema.json +0 -2512
  118. package/docs/provider-check-input.schema.json +0 -7
  119. package/docs/rebuild-to-v2.md +0 -511
  120. package/docs/workspace-adoption.md +0 -74
  121. package/injects/framework-workspace.md +0 -7
  122. package/injects/local-soul.md +0 -19
  123. package/injects/oats-portable.md +0 -20
  124. package/injects/oats.md +0 -11
  125. package/injects/portable-instance-boundary.md +0 -39
  126. package/injects/portable-work-directory.md +0 -29
  127. package/lib/artifact-approvals.mjs +0 -120
  128. package/lib/artifact-tree.mjs +0 -141
  129. package/lib/capability-artifacts.mjs +0 -179
  130. package/lib/capability-execution.mjs +0 -15
  131. package/lib/capability-inputs.mjs +0 -39
  132. package/lib/capability-provenance.mjs +0 -231
  133. package/lib/captured-action-shape.mjs +0 -21
  134. package/lib/captured-admission-shape.mjs +0 -20
  135. package/lib/captured-binding-file.mjs +0 -36
  136. package/lib/captured-dispatch.mjs +0 -66
  137. package/lib/captured-instance-index.mjs +0 -277
  138. package/lib/captured-invocation-context.mjs +0 -130
  139. package/lib/captured-launch-request.mjs +0 -66
  140. package/lib/captured-operation-process.mjs +0 -15
  141. package/lib/captured-pi-custody.mjs +0 -29
  142. package/lib/captured-pi-host.mjs +0 -167
  143. package/lib/captured-pi-outcome.mjs +0 -172
  144. package/lib/captured-resolutions.mjs +0 -275
  145. package/lib/captured-scaffold.mjs +0 -87
  146. package/lib/captured-selector.mjs +0 -28
  147. package/lib/captured-session-backend.mjs +0 -52
  148. package/lib/captured-source-receipt-file.mjs +0 -72
  149. package/lib/helper-injection-policy.mjs +0 -104
  150. package/lib/legacy-lock-codec.mjs +0 -106
  151. package/lib/manifest-settings.mjs +0 -84
  152. package/lib/package-closure.mjs +0 -48
  153. package/lib/package-materialization.mjs +0 -83
  154. package/lib/pi-sdk-host.mjs +0 -229
  155. package/lib/portable-artifacts.mjs +0 -115
  156. package/lib/portable-choices.mjs +0 -82
  157. package/lib/portable-composition.mjs +0 -136
  158. package/lib/portable-digest.mjs +0 -105
  159. package/lib/portable-identity.mjs +0 -40
  160. package/lib/portable-lock.mjs +0 -117
  161. package/lib/portable-onboarding-request.mjs +0 -49
  162. package/lib/portable-onboarding.mjs +0 -256
  163. package/lib/portable-package-preparation.mjs +0 -188
  164. package/lib/portable-policy.mjs +0 -44
  165. package/lib/portable-soul.mjs +0 -42
  166. package/lib/portable-state.mjs +0 -80
  167. package/lib/prepare-composition.mjs +0 -170
  168. package/lib/prepared-bindings.mjs +0 -92
  169. package/lib/prepared-resources.mjs +0 -127
  170. package/lib/provider-binding-broker.mjs +0 -65
  171. package/lib/provider-binding-wire.mjs +0 -116
  172. package/lib/readiness.mjs +0 -225
  173. package/lib/repository-observation.mjs +0 -226
  174. package/lib/resolution-shape.mjs +0 -393
  175. package/lib/schedule-capsule.mjs +0 -206
  176. package/lib/soul-constraints.mjs +0 -40
  177. package/lib/source-projection.mjs +0 -84
  178. package/lib/source-spec.mjs +0 -189
  179. package/lib/workspace-definition.mjs +0 -126
  180. package/lib/workspace-discovery.mjs +0 -146
  181. package/skills/oats/SKILL.md +0 -162
  182. package/skills/oats-config/SKILL.md +0 -164
  183. package/skills/oats-packages/SKILL.md +0 -184
  184. package/skills/oats-portable/SKILL.md +0 -115
  185. package/skills/oats-portable-artifacts/SKILL.md +0 -63
package/lib/resolve.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  * `resolveSoul(discovery, soulEntry, options)` turns a discovered soul into the
8
8
  * exact set of modules an instance will be built from: which capability comes
9
9
  * from where (a confirmed member at its latest commit, or a locked package at
10
- * its pinned commit), which module fills each fundamental slot, the merged
10
+ * its pinned commit), which module fills each core-capability slot, the merged
11
11
  * provider payload per capability, the composed skill set, the injects, and a
12
12
  * `revision` that changes whenever any of that changes.
13
13
  *
@@ -18,14 +18,16 @@
18
18
  * is usable only from its own repo (E_CAPABILITY_PRIVATE). It NEVER looks
19
19
  * inside the repo's oats-package/ (non-collapse rule): a name that exists only
20
20
  * there fails with details.hint "provided by package <id>; use from: package".
21
- * - `from: package` → the lock's packageProviding(name) (E_PACKAGE_MISSING), approved
22
- * (E_PACKAGE_UNAPPROVED). It NEVER looks at member capabilities, even when the
21
+ * - `from: package` → the lock's packageProviding(name) (E_PACKAGE_MISSING). There is no approval
22
+ * gate: declaring the package in `packages:` is the trust decision (human
23
+ * decision 2026-09-24). It NEVER looks at member capabilities, even when the
23
24
  * package's repo is a member.
24
25
  *
25
26
  * Composition order (soul wins; `off` removes):
26
27
  * workspace.defaults.{knowledge,messaging,tasks} (slot defaults; a soul `none` drops them)
27
- * ⊕ workspace.defaults.capabilities ⊕ workspace.defaults.byTeam[soul.team].capabilities
28
- * ⊕ soul.capabilities
28
+ * ⊕ workspace.defaults.capabilities ⊕ workspace.defaults.byTeam[<label>].capabilities for each of the
29
+ * soul's team labels, in order ⊕ soul.capabilities. Two labels that give one capability different
30
+ * entries → E_TEAM_CONFLICT naming both (teams contract 2026-09-25, decision 2).
29
31
  *
30
32
  * Slot `none` (contract §3, post-0.25.0 rule): a soul's `<slot>: none` EMPTIES the slot — it drops the
31
33
  * workspace's `defaults.<slot>` AND any capability of that layer the workspace defaults contributed
@@ -33,10 +35,9 @@
33
35
  * next to `none` is contradictory and stays E_SLOT_CONFLICT { reason: "none" } (spell `<cap>: off` to
34
36
  * remove a default explicitly; drop the soul's own line to fill the slot).
35
37
  *
36
- * Package modules are gated twice (decision 8): the lock must carry an approval AND the approval must
37
- * describe the package tree at the locked commit — `executablesDigestAt(...)` (the same computation
38
- * `oats sync` approved) must equal `approved.executables`, else E_PACKAGE_UNAPPROVED
39
- * { reason: "digest-mismatch", approved, executables }. An edited lock never runs unapproved hooks.
38
+ * Package modules are read at the locked commit, and the lock must describe that tree: the capability
39
+ * list `<path>/oats-package.json` declares there must equal the entry's `capabilities`, else
40
+ * E_PACKAGE_INTEGRITY { why: "capabilities", listed, locked }.
40
41
  *
41
42
  * Revision (decision 14 + preview): `declRevision` fingerprints the declarations (soul identity, modules
42
43
  * with their commits/versions/manifests, slots, skills, injects); `payloadRevision` fingerprints the merged
@@ -44,7 +45,8 @@
44
45
  * everything, while a preview can say WHAT changed since the previous instance (declarations | payload | both).
45
46
  *
46
47
  * Payloads (decision 14), later wins on scalars/arrays, objects deep-merge:
47
- * workspace.messaging (messaging slot only) ⊕ soul.<slot> ⊕ local.settings[cap] ⊕ spawn.providers[cap]
48
+ * workspace.messaging (messaging slot only; its base, `byTeam` stripped and never merged — teams amendment K)
49
+ * ⊕ soul.<slot> ⊕ local.settings[cap] ⊕ spawn.providers[cap]
48
50
  *
49
51
  * This module shells out to nothing. Remote access is injected (`remote`, default
50
52
  * lib/remote.mjs; `remoteOptions` threaded into every call). `resolveSoul` is
@@ -52,10 +54,12 @@
52
54
  * over the remote; everything else is pure and exported for direct testing.
53
55
  */
54
56
  import { createHash } from "node:crypto";
57
+ import { readFileSync } from "node:fs";
55
58
  import { posix } from "node:path";
56
59
  import { oatsError as baseOatsError } from "./errors.mjs";
57
60
  import * as defaultRemote from "./remote.mjs";
58
- import { bindRemote, executablesDigestAt, packageProviding, readPackageManifests, validateLock } from "./packages.mjs";
61
+ import { bindRemote, packageProviding, readPackageManifests, validateLock } from "./packages.mjs";
62
+ import { settingValueProblems } from "./capability-contract.mjs";
59
63
 
60
64
  export const RESOLUTION_API = 1;
61
65
  export const SLOTS = Object.freeze(["knowledge", "messaging", "tasks"]);
@@ -110,6 +114,42 @@ function mergeInto(base, over) {
110
114
  }
111
115
  const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
112
116
 
117
+ /** A manifest's declared setting defaults (`settings.<key>.default`) as a payload layer: the
118
+ * intrinsic field fallbacks, merged BELOW every workspace, soul, host and spawn layer. */
119
+ export function manifestDefaultsPayload(manifest) {
120
+ const out = {};
121
+ if (!isObject(manifest?.settings)) return out;
122
+ for (const [key, declaration] of Object.entries(manifest.settings)) {
123
+ if (isObject(declaration) && Object.hasOwn(declaration, "default") && declaration.default !== undefined) out[key] = clone(declaration.default);
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /** Where each leaf of a merged payload came from: JSON pointer → the origin of the LAST layer that
129
+ * set it, under mergePayload's rules (objects merge recursively; arrays and scalars replace, taking
130
+ * every leaf below them with them). `layers` is [{ payload, origin }] in merge order. */
131
+ export function payloadOrigins(layers) {
132
+ const origins = {};
133
+ const escape = (k) => String(k).replace(/~/g, "~0").replace(/\//g, "~1");
134
+ const dropBelow = (pointer) => { for (const p of Object.keys(origins)) if (p === pointer || p.startsWith(`${pointer}/`)) delete origins[p]; };
135
+ const walk = (value, pointer, origin, mergedBefore) => {
136
+ for (const [k, v] of Object.entries(value)) {
137
+ if (v === undefined) continue;
138
+ const at = `${pointer}/${escape(k)}`;
139
+ if (isObject(v) && isObject(mergedBefore?.[k])) { delete origins[at]; walk(v, at, origin, mergedBefore[k]); }
140
+ else if (isObject(v) && Object.keys(v).length) { dropBelow(at); walk(v, at, origin, null); }
141
+ else { dropBelow(at); origins[at] = origin; }
142
+ }
143
+ };
144
+ let merged = {};
145
+ for (const { payload, origin } of layers) {
146
+ if (!isObject(payload)) continue;
147
+ walk(payload, "", origin, merged);
148
+ merged = mergeInto(merged, payload);
149
+ }
150
+ return origins;
151
+ }
152
+
113
153
  /** `byTeam` is RESERVED (decision 23): it addresses a per-team payload and is legal only at the top level of
114
154
  * workspace.messaging, where the resolver merges base ⊕ byTeam[soul.team] and strips it. In any other payload
115
155
  * layer — a soul's slot payload, local.settings[cap], spawn.providers[cap] — it would reach the provider
@@ -265,6 +305,22 @@ export function satisfiesRange(version, range) {
265
305
  return false;
266
306
  }
267
307
 
308
+ /** The running kernel's version: a capability's `compatibility.oats` range is checked against it. */
309
+ export const KERNEL_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
310
+
311
+ /** A capability manifest's own kernel range (`compatibility.oats`) against `kernel`:
312
+ * `{ ok, range, kernel }`; `range: null` (always ok) when the manifest declares none. Throws
313
+ * E_CAPABILITY_INCOMPATIBLE { why: "range" } for a range that is not a version range. */
314
+ export function kernelCompatibility(manifest, kernel = KERNEL_VERSION) {
315
+ const range = isObject(manifest?.compatibility) && Object.hasOwn(manifest.compatibility, "oats") ? manifest.compatibility.oats : null;
316
+ if (range === null) return { ok: true, range: null, kernel };
317
+ try { return { ok: satisfiesRange(kernel, range), range, kernel }; }
318
+ catch (e) {
319
+ if (e?.code !== "E_COMPATIBILITY") throw e;
320
+ throw fail("E_CAPABILITY_INCOMPATIBLE", `${manifest?.capability ?? "capability"}: compatibility.oats ${show(range)} is not a version range`, { capability: manifest?.capability ?? null, range, kernel, why: "range" });
321
+ }
322
+ }
323
+
268
324
  /* ───────────────────────────── composition ────────────────────────────── */
269
325
 
270
326
  const choiceOf = (value, path, via) => {
@@ -277,13 +333,43 @@ const choiceOf = (value, path, via) => {
277
333
  throw fail("E_WORKSPACE_SCHEMA", `${path} must be { from: <location> } or "off", got ${show(value)}`, { path, value });
278
334
  };
279
335
 
336
+ /** A soul's team labels, primary first: `team` is a label or a list of distinct labels (teams
337
+ * contract 2026-09-25, decision 1). A discovery SoulEntry carries them as `labels`; an entry
338
+ * without them (a hand-built one) has its `team` as the only label. */
339
+ export function teamLabelsOf(soulEntry) {
340
+ if (Array.isArray(soulEntry?.labels)) return soulEntry.labels.filter((l) => typeof l === "string");
341
+ return typeof soulEntry?.team === "string" ? [soulEntry.team] : [];
342
+ }
343
+
344
+ /**
345
+ * The eligible teams of a soul (teams contract, decision 3): one entry per label, in soul order,
346
+ * `{ label, team, mapped, payload }`. `payload` is workspace.messaging's base ⊕ byTeam[label] when the
347
+ * workspace maps the label, else the base alone with `mapped: false`; `team` is the payload's team id
348
+ * when mapped (null otherwise). Kernel-owned and delivered BESIDE a provider's settings, never inside
349
+ * them; joining any of them is the messaging provider's explicit act. No label → [] ("personal only").
350
+ * Pure: resolveSoul validates every carried label's entry (reserved byTeam, hostOnly keys) first.
351
+ */
352
+ export function teamsOf(workspace, labels) {
353
+ const messaging = isObject(workspace?.messaging) ? workspace.messaging : {};
354
+ const { byTeam, ...base } = messaging;
355
+ return (labels || []).map((label) => {
356
+ const mapped = isObject(byTeam) && Object.hasOwn(byTeam, label) && isObject(byTeam[label]);
357
+ const payload = mapped ? mergePayload(base, byTeam[label]) : mergePayload(base);
358
+ return { label, team: mapped && typeof payload.team === "string" ? payload.team : null, mapped, payload };
359
+ });
360
+ }
361
+
280
362
  /**
281
363
  * The ordered capability map of a soul BEFORE any lookup:
282
- * slot defaults (dropped where the soul says `none`) ⊕ defaults.capabilities ⊕ defaults.byTeam[team] ⊕ soul.capabilities
364
+ * slot defaults (dropped where the soul says `none`) ⊕ defaults.capabilities ⊕ defaults.byTeam[label] for
365
+ * each label in order ⊕ soul.capabilities
283
366
  * → [{ name, from, via }] sorted by name; `off` removes the entry from every lower layer.
284
- * `via` is one of "defaults.<slot>" | "defaults.capabilities" | "defaults.byTeam.<team>" | "soul".
367
+ * `via` is one of "defaults.<slot>" | "defaults.capabilities" | "defaults.byTeam.<label>" | "soul".
368
+ * Two labels that give one capability different entries (`off` vs a location, or two locations) →
369
+ * E_TEAM_CONFLICT { capability, labels: [a, b] }; identical entries are not a conflict, and a capability
370
+ * the soul names itself is not one either (the soul's entry wins over both).
285
371
  */
286
- export function composeCapabilities(workspace, soulDefinition, { team = null } = {}) {
372
+ export function composeCapabilities(workspace, soulDefinition, { team = null, labels = team === null ? [] : [team] } = {}) {
287
373
  const map = new Map();
288
374
  const apply = (entries, via, path) => {
289
375
  for (const [name, value] of Object.entries(entries || {})) {
@@ -301,8 +387,21 @@ export function composeCapabilities(workspace, soulDefinition, { team = null } =
301
387
  apply(d, `defaults.${slot}`, `/defaults/${slot}`);
302
388
  }
303
389
  apply(defaults.capabilities, "defaults.capabilities", "/defaults/capabilities");
304
- if (team !== null && isObject(defaults.byTeam) && isObject(defaults.byTeam[team])) {
305
- apply(defaults.byTeam[team].capabilities, `defaults.byTeam.${team}`, `/defaults/byTeam/${team}/capabilities`);
390
+ const byLabel = new Map(); // capability -> { label, choice } of the first label that set it
391
+ // The soul's own entry wins over every label, so a capability the soul names settles the conflict.
392
+ const soulNames = new Set(Object.keys(isObject(soulDefinition?.capabilities) ? soulDefinition.capabilities : {}));
393
+ for (const label of labels) {
394
+ if (!isObject(defaults.byTeam) || !Object.hasOwn(defaults.byTeam, label) || !isObject(defaults.byTeam[label])) continue;
395
+ const entries = defaults.byTeam[label].capabilities, path = `/defaults/byTeam/${label}/capabilities`;
396
+ for (const [name, value] of Object.entries(entries || {})) {
397
+ const choice = choiceOf(value, `${path}/${name}`, `defaults.byTeam.${label}`);
398
+ const seen = byLabel.get(name);
399
+ if (seen && !soulNames.has(name) && canonicalJson(seen.choice) !== canonicalJson(choice)) {
400
+ throw fail("E_TEAM_CONFLICT", `${name}: team labels ${show(seen.label)} and ${show(label)} give it different entries (${canonicalJson(seen.choice)} vs ${canonicalJson(choice)}) in defaults.byTeam — make them agree, or name ${name} in the soul`, { capability: name, labels: [seen.label, label], entries: [seen.choice, choice], paths: [`/defaults/byTeam/${seen.label}/capabilities/${name}`, `${path}/${name}`] });
401
+ }
402
+ if (!seen) byLabel.set(name, { label, choice });
403
+ }
404
+ apply(entries, `defaults.byTeam.${label}`, path);
306
405
  }
307
406
  apply(soulDefinition?.capabilities, "soul", "/capabilities");
308
407
  return [...map.values()].sort((a, b) => byCodepoint(a.name, b.name));
@@ -315,7 +414,7 @@ function memberRow(discovery, repoKey) {
315
414
  }
316
415
 
317
416
  /** The ref the workspace lists a member under (keeps the operator's spelling: ssh vs https); else from the key. */
318
- function memberRef(discovery, remote, repoKey) {
417
+ export function memberRef(discovery, remote, repoKey) {
319
418
  for (const ref of discovery?.workspace?.members || []) {
320
419
  try { if (remote.parseRepoRef(ref).key === repoKey) return ref; } catch { /* validated upstream */ }
321
420
  }
@@ -338,6 +437,9 @@ function lookupMember(discovery, soul, name, from, via, lock) {
338
437
  const cap = (row.capabilities || []).find((c) => c.name === name) || null;
339
438
  if (!cap) {
340
439
  const details = { ...where, commit: row.commit };
440
+ // Discovery found the manifest but refused it (lib/capability-contract.mjs): say that, not "missing".
441
+ const refused = (discovery?.problems || []).filter((p) => p.repoKey === repoKey && typeof p.path === "string" && p.path.startsWith(`capabilities/${name}/oats.json#`));
442
+ if (refused.length) throw fail("E_WORKSPACE_SCHEMA", `${name}: ${repoKey}@${short(row.commit)} declares it, but its manifest is refused: ${refused.map((p) => `${p.path}: ${p.message}`).join("; ")}`, { ...details, problems: refused, reason: "manifest-contract" });
341
443
  // The name may live in a package tier: say so, but never resolve it from here (decision 19).
342
444
  const providing = safePackageProviding(lock, name);
343
445
  let hint = null;
@@ -355,44 +457,21 @@ function safePackageProviding(lock, name) {
355
457
  try { return packageProviding(lock, name); } catch { return null; }
356
458
  }
357
459
 
358
- /** Resolve `from: package` through the lock only — never through member capabilities. */
359
- function lookupPackage(lock, name, via, soul) {
460
+ /** Resolve `from: package` through the lock only — never through member capabilities.
461
+ * Declaring a package in the workspace's packages: is the trust decision (human
462
+ * decision 2026-09-24), so a workspace view admits only a locked package the
463
+ * workspace STILL declares: a package removed from packages: but left in a stale
464
+ * lock (no `oats sync` since) is refused, never materialized. `declared` is null
465
+ * for a standalone view — its lock holds only what a standalone sync wrote. */
466
+ function lookupPackage(lock, name, via, soul, declared) {
360
467
  const where = { capability: name, from: "package", soul: soul.name, via };
361
468
  if (!isObject(lock) || !isObject(lock.packages)) throw fail("E_PACKAGE_MISSING", `${name}: from: package needs the workspace lock (oats-lock.json v3) — run \`oats sync\``, { ...where, reason: "no-lock" });
362
469
  const providing = packageProviding(lock, name);
363
470
  if (!providing) throw fail("E_PACKAGE_MISSING", `${name}: no locked package provides it — add the package to packages: and run \`oats sync\``, { ...where, locked: Object.keys(lock.packages).sort() });
364
- if (!providing.entry.approved || !isObject(providing.entry.approved) || typeof providing.entry.approved.executables !== "string" || !/^sha256-[0-9a-f]{64}$/.test(providing.entry.approved.executables)) {
365
- throw fail("E_PACKAGE_UNAPPROVED", `${name}: package ${providing.id} v${providing.entry.version} (${short(providing.entry.commit)}) is not approved — review its executables and approve once per version`, { ...where, id: providing.id, version: providing.entry.version, commit: providing.entry.commit, reason: "unapproved" });
366
- }
471
+ if (declared && !Object.hasOwn(declared, providing.id)) throw fail("E_PACKAGE_MISSING", `${name}: the lock's package ${providing.id} is no longer declared in the workspace's packages: — run \`oats sync\` (or declare it again)`, { ...where, id: providing.id, reason: "undeclared" });
367
472
  return providing;
368
473
  }
369
474
 
370
- /**
371
- * The approval must describe THIS tree (decision 8: the lock carries the approval next to the commit it
372
- * approved). Recompute the executables digest over the package tree at entry.commit — the very computation
373
- * `oats sync` approved — and require equality with approved.executables. A lock edited to another commit
374
- * (same id/version, approval copied along) fails here: E_PACKAGE_UNAPPROVED { reason: "digest-mismatch" }
375
- * naming both digests and the executables that would have run. Cached per (remote, repo, commit, path).
376
- */
377
- async function assertApprovalDescribesTree({ remote, remoteOptions, ref, id, entry, name, via, soul }) {
378
- const where = { capability: name, from: "package", soul: soul.name, via, id, version: entry.version, commit: entry.commit, path: entry.path };
379
- let computed;
380
- try { computed = await executablesDigestAt(remote, ref, entry.commit, entry.path, Array.isArray(entry.capabilities) ? entry.capabilities : null, { remoteOptions }); }
381
- catch (e) {
382
- if (e?.code === "E_PACKAGE_INTEGRITY" && (e.details ?? e.provenance)?.why === "capabilities") {
383
- const d = e.details ?? e.provenance;
384
- throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides [${d.locked.join(", ")}], but ${entry.path}/oats-package.json at ${short(entry.commit)} declares [${d.listed.join(", ")}]`, { ...where, why: "capabilities", listed: d.listed, locked: d.locked });
385
- }
386
- throw e;
387
- }
388
- if (computed.digest !== entry.approved.executables) {
389
- throw fail("E_PACKAGE_UNAPPROVED",
390
- `${name}: package ${id} v${entry.version} @ ${short(entry.commit)}: the recorded approval ${entry.approved.executables} does not describe this tree's executables (${computed.digest}) — the lock was edited or the approval copied from another commit; run \`oats sync\` and approve what it shows`,
391
- { ...where, reason: "digest-mismatch", approved: entry.approved.executables, executables: computed.digest, targets: computed.executables.map((x) => `${x.capability}: ${x.kind} ${x.name} → ${x.target}`) });
392
- }
393
- return computed;
394
- }
395
-
396
475
  /** The repo ref a locked package is read from: the lock's recorded url, else the catalog's url for catalog ids, else the key for git refs. */
397
476
  export function packageRef(id, entry, catalog, remote) {
398
477
  const cat = isObject(catalog) ? (isObject(catalog.packages) && !("url" in catalog.packages) ? catalog.packages : catalog) : {};
@@ -470,28 +549,28 @@ async function enumerateSkills({ remote, ref, commit, dir, manifest, listing, mo
470
549
  * options: { local, lock, spawn = { providers? }, catalog, remote, remoteOptions }
471
550
  * `catalog` (package-catalog.json shape) tells which repo a `catalog:<id>` lock entry is read from.
472
551
  *
473
- * → Resolution { resolutionApi: 1, soul, modules[], slots, payloads, skills[], injects[], revision } — deep-frozen.
552
+ * → Resolution { resolutionApi: 1, soul, modules[], slots, payloads, payloadOrigins, skills[], injects[], revision } — deep-frozen.
474
553
  * Extra fields beyond the contract (recorded for materialize): module.dir (capability dir, repo-relative)
475
554
  * and from.repoKey on package modules.
476
555
  */
477
- export async function resolveSoul(discovery, soulEntry, { local = null, lock = null, spawn = {}, catalog = null, remote: injected, remoteOptions } = {}) {
556
+ export async function resolveSoul(discovery, soulEntry, { local = null, lock = null, spawn = {}, catalog = null, remote: injected, remoteOptions, kernel = KERNEL_VERSION } = {}) {
478
557
  if (!isObject(soulEntry) || typeof soulEntry.name !== "string" || typeof soulEntry.repoKey !== "string") {
479
558
  throw new TypeError("resolveSoul: soulEntry must be a discovery SoulEntry { name, path, repoKey, commit, team, private, definition }");
480
559
  }
481
560
  if (!isObject(spawn)) throw new TypeError("resolveSoul: spawn must be an object");
482
561
  if (lock !== null && lock !== undefined) validateLock(lock); // E_LOCK_SCHEMA: a lock passed in memory meets the same bar as one read from disk
483
- const rawRemote = injected ?? defaultRemote; // identity for the per-process digest cache (bound copies are per call)
484
562
  const remote = remoteOf({ remote: injected, remoteOptions });
485
563
  const workspace = isObject(discovery?.workspace) ? discovery.workspace : null;
486
564
  assertSoulDiscovered(discovery, soulEntry);
487
565
  const definition = isObject(soulEntry.definition) ? soulEntry.definition : {};
488
- const team = typeof soulEntry.team === "string" ? soulEntry.team : null;
566
+ const labels = teamLabelsOf(soulEntry);
567
+ const team = labels[0] ?? null; // the PRIMARY label: the merged messaging payload and OATS_TEAM_LABEL follow it
489
568
  const soul = { name: soulEntry.name, repoKey: soulEntry.repoKey, commit: soulEntry.commit ?? null, team, path: soulEntry.path ?? null };
490
569
 
491
570
  // Standalone: the soul's own repo only, workspace defaults unknown (decision 10).
492
571
  const declared = discovery?.standalone === true
493
- ? composeCapabilities(null, { ...definition, capabilities: soulEntry.capabilities ?? definition.capabilities }, { team })
494
- : composeCapabilities(workspace, definition, { team });
572
+ ? composeCapabilities(null, { ...definition, capabilities: soulEntry.capabilities ?? definition.capabilities }, { labels })
573
+ : composeCapabilities(workspace, definition, { labels });
495
574
 
496
575
  const modules = [];
497
576
  const skills = [];
@@ -504,12 +583,15 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
504
583
  for (const { name, from, via } of declared) {
505
584
  let module;
506
585
  if (from === "package") {
507
- const { id, entry } = lookupPackage(lock, name, via, soul);
586
+ const { id, entry } = lookupPackage(lock, name, via, soul, discovery?.standalone === true ? null : (isObject(workspace?.packages) ? workspace.packages : {}));
508
587
  const ref = packageRef(id, entry, catalog, remote);
509
588
  const details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path };
510
- // M3: the approval must describe the tree at entry.commit (the digest `oats sync` approved), else refuse.
511
- await assertApprovalDescribesTree({ remote: rawRemote, remoteOptions, ref, id, entry, name, via, soul });
512
589
  const { capabilities } = await readPackageManifests(remote, ref, entry.commit, entry.path, details);
590
+ // The lock must describe the tree it names: its capability list is what the package declares there.
591
+ const listed = capabilities.map((c) => c.name).sort(), locked = [...entry.capabilities].sort(); // validateLock guarantees the array
592
+ if (listed.length !== locked.length || listed.some((c, i) => c !== locked[i])) {
593
+ throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides [${locked.join(", ")}], but ${entry.path}/oats-package.json at ${short(entry.commit)} declares [${listed.join(", ")}]`, { ...details, why: "capabilities", listed, locked });
594
+ }
513
595
  const cap = capabilities.find((c) => c.name === name);
514
596
  if (!cap) throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides it, but ${entry.path}/oats-package.json at ${short(entry.commit)} does not`, { ...details, listed: capabilities.map((c) => c.name) });
515
597
  const layer = layerOf(cap.manifest);
@@ -545,6 +627,7 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
545
627
  // the soul's own contradiction → E_SLOT_CONFLICT { reason: "none" } — judged FIRST, so it is never reported
546
628
  // as a two-module clash.
547
629
  const slots = { knowledge: null, messaging: null, tasks: null };
630
+ const slotsFrom = { knowledge: null, messaging: null, tasks: null };
548
631
  const viaOf = new Map(declared.map((d) => [d.name, d.via]));
549
632
  for (const m of modules) {
550
633
  const via = viaOf.get(m.name);
@@ -557,6 +640,7 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
557
640
  if (emptied.has(m.layer)) throw fail("E_SLOT_CONFLICT", `slot ${m.layer}: the soul says ${m.layer}: none but itself names ${m.name}, which declares layer ${m.layer} — drop one of the two (a workspace default of that layer would have been dropped by none; this one is the soul's own)`, { slot: m.layer, modules: [m.name], soul: soul.name, reason: "none", via });
558
641
  if (slots[m.layer]) throw fail("E_SLOT_CONFLICT", `slot ${m.layer}: both ${slots[m.layer]} and ${m.name} declare layer ${m.layer}; a soul fills each slot with at most one capability`, { slot: m.layer, modules: [slots[m.layer], m.name], soul: soul.name });
559
642
  slots[m.layer] = m.name;
643
+ slotsFrom[m.layer] = fromOfVia(via);
560
644
  }
561
645
 
562
646
  // Duplicate skill names within the composed set (decision 16).
@@ -569,7 +653,8 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
569
653
  seenSkills.set(s.name, s);
570
654
  }
571
655
 
572
- // Payloads (decision 14): workspace.messaging (messaging slot) ⊕ soul.<slot> ⊕ local.settings[cap] ⊕ spawn.providers[cap].
656
+ // Payloads (decision 14; addendum 5): manifest defaults ⊕ workspace.messaging (messaging slot) ⊕ soul.<slot>
657
+ // ⊕ local.settings[cap] ⊕ spawn.providers[cap], each leaf's origin kept in payloadOrigins.
573
658
  const settings = isObject(local?.settings) ? local.settings : {};
574
659
  const providers = isObject(spawn.providers) ? spawn.providers : {};
575
660
  const moduleNames = new Set(modules.map((m) => m.name));
@@ -580,28 +665,63 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
580
665
  assertNoReservedKey(value, `/spawn/providers/${cap}`);
581
666
  if (!moduleNames.has(cap)) throw fail("E_CAPABILITY_MISSING", `--provider ${cap}: the soul ${soul.name} does not resolve a capability named ${show(cap)} (modules: ${[...moduleNames].sort().join(", ") || "none"})`, { capability: cap, soul: soul.name, hint: "a provider payload targets one of the soul's resolved capabilities", modules: [...moduleNames].sort() });
582
667
  }
583
- const payloads = {};
668
+ const payloads = {}, origins = {};
584
669
  // The soul's slot payloads are checked whether or not the slot resolves to a module: a reserved key is a
585
670
  // schema fault of the soul, not of the spawn that happened to fill the slot.
586
671
  for (const slot of SLOTS) if (isObject(definition[slot])) assertNoReservedKey(definition[slot], `/${slot}`);
587
672
  for (const m of modules) {
588
- const layers = [];
673
+ // Addendum 5: the manifest's declared defaults are the LOWEST layer, so the preview shows (and the
674
+ // provider receives) e.g. identity.mode = local with its origin, not an absent key.
675
+ const layers = [], sourced = [];
676
+ const add = (payload, origin) => { layers.push(payload); sourced.push({ payload, origin }); };
677
+ for (const [key, value] of Object.entries(manifestDefaultsPayload(m.manifest))) {
678
+ add({ [key]: value }, { kind: "manifest-default", at: `oats.json#/settings/${key.replace(/~/g, "~0").replace(/\//g, "~1")}/default` });
679
+ }
589
680
  // Decision 27 (K1″): a manifest may mark a settings key `hostOnly` — a host fact (a custody
590
681
  // path, a state directory) that only the deployment's own oats-local.yaml may supply. Every
591
682
  // committed or per-spawn layer is refused with that key present, BEFORE the merge, because the
592
683
  // provider receives one merged payload without provenance and cannot enforce this itself.
593
684
  const hostOnly = hostOnlyKeys(m.manifest);
594
- const committed = (payload, path) => { assertNoHostOnlyKey(payload, path, hostOnly, m.name); layers.push(payload); };
685
+ const committed = (payload, path, origin) => { assertNoHostOnlyKey(payload, path, hostOnly, m.name); add(payload, origin); };
595
686
  if (m.layer === "messaging" && isObject(workspace?.messaging)) {
596
- // Decision 23: base ⊕ byTeam[soul.team]; `byTeam` never reaches the provider (nor may a team's own payload nest one).
687
+ // Decision 23 as amended by K (teams contract, co-lead ruling): the provider's settings are
688
+ // base ⊕ soul ⊕ host ⊕ spawn. No byTeam[<label>] is merged, the primary's included: each
689
+ // label's base ⊕ byTeam[label] is delivered only in its `teams` entry (teamsOf → OATS_TEAMS),
690
+ // so settings.team is the personal team a host, soul or spawn set. `byTeam` never reaches the
691
+ // provider, and the primary team's own payload may still not nest one.
597
692
  const { byTeam, ...base } = workspace.messaging;
598
- committed(base, "/messaging");
599
- if (team !== null && isObject(byTeam) && isObject(byTeam[team])) { assertNoReservedKey(byTeam[team], `/messaging/byTeam/${team}`); committed(byTeam[team], `/messaging/byTeam/${team}`); }
693
+ committed(base, "/messaging", { kind: "workspace", at: "oats-workspace.yaml#/messaging" });
694
+ // Every label's entry still reaches the provider, in OATS_TEAMS (teamsOf), so each one the soul
695
+ // carries — the primary's and every other — may nest no byTeam and carry no hostOnly key.
696
+ for (const label of labels) {
697
+ if (!isObject(byTeam) || !Object.hasOwn(byTeam, label) || !isObject(byTeam[label])) continue;
698
+ assertNoReservedKey(byTeam[label], `/messaging/byTeam/${label}`);
699
+ assertNoHostOnlyKey(byTeam[label], `/messaging/byTeam/${label}`, hostOnly, m.name);
700
+ }
600
701
  }
601
- if (m.layer && isObject(definition[m.layer])) { assertNoReservedKey(definition[m.layer], `/${m.layer}`); committed(definition[m.layer], `/${m.layer}`); }
602
- if (Object.hasOwn(settings, m.name) && isObject(settings[m.name])) { assertNoReservedKey(settings[m.name], `/settings/${m.name}`); layers.push(settings[m.name]); } // the host layer: hostOnly keys are legal here
603
- if (Object.hasOwn(providers, m.name) && isObject(providers[m.name])) committed(providers[m.name], `/spawn/providers/${m.name}`);
702
+ if (m.layer && isObject(definition[m.layer])) { assertNoReservedKey(definition[m.layer], `/${m.layer}`); committed(definition[m.layer], `/${m.layer}`, { kind: "soul", at: `soul.yaml#/${m.layer}` }); }
703
+ if (Object.hasOwn(settings, m.name) && isObject(settings[m.name])) { assertNoReservedKey(settings[m.name], `/settings/${m.name}`); add(settings[m.name], { kind: "host", at: `oats-local.yaml#/settings/${m.name}` }); } // the host layer: hostOnly keys are legal here
704
+ if (Object.hasOwn(providers, m.name) && isObject(providers[m.name])) committed(providers[m.name], `/spawn/providers/${m.name}`, { kind: "spawn", at: `--provider ${m.name}` });
604
705
  payloads[m.name] = mergePayload(...layers);
706
+ origins[m.name] = payloadOrigins(sourced);
707
+ // A value outside the manifest's `settings.<key>.values` is refused where it was set: a
708
+ // conditional `requires` row reads it, so a typo would silently skip every such row.
709
+ for (const { key, value, values } of settingValueProblems(m.manifest, payloads[m.name])) {
710
+ const at = origins[m.name][`/${key.replace(/~/g, "~0").replace(/\//g, "~1")}`]?.at ?? null;
711
+ throw fail("E_WORKSPACE_SCHEMA", `${m.name}: setting ${show(key)} is ${show(value)}, not one of ${values.map(show).join(", ")}${at ? ` (set at ${at})` : ""}`, { capability: m.name, key, value, values, at, reason: "setting-value" });
712
+ }
713
+ }
714
+
715
+ // Each capability's own kernel range (its manifest's compatibility.oats) must admit the running
716
+ // kernel: a package or member capability written for another kernel is refused before anything
717
+ // is composed from it, never discovered at its first hook.
718
+ for (const m of modules) {
719
+ const c = kernelCompatibility({ ...m.manifest, capability: m.name }, kernel);
720
+ if (c.ok) continue;
721
+ const remedy = m.from.kind === "package"
722
+ ? `pin a release of ${m.from.package} whose range admits ${kernel} in the workspace's packages:, or run a kernel the range admits`
723
+ : `update ${m.name} in ${m.from.repoKey}, or run a kernel the range admits`;
724
+ throw fail("E_CAPABILITY_INCOMPATIBLE", `${m.name} requires oats ${c.range}; this kernel is ${kernel} — ${remedy}`, { capability: m.name, range: c.range, kernel, from: m.from });
605
725
  }
606
726
 
607
727
  // Compatibility floors (soul.compatibility) are constraints on PACKAGE versions.
@@ -630,7 +750,23 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
630
750
  const declRevision = revisionOf(decl);
631
751
  const payloadRevision = revisionOf(payloads);
632
752
  const revision = revisionOf({ declRevision, payloadRevision });
633
- return deepFreeze({ ...decl, payloads, declRevision, payloadRevision, revision });
753
+ // payloadOrigins is provenance only: it is derived from the same inputs the two revisions already
754
+ // bind, so it does not enter either fingerprint. `teams` (the eligible teams, teams contract
755
+ // decision 3) is live messaging state, not composition: it stays out of both fingerprints too, so a
756
+ // single-label soul's revision is what it was.
757
+ // `slotsFrom` (where each filled slot's capability came from) is provenance, like payloadOrigins: outside
758
+ // both fingerprints, so the same capability reached another way is not a composition change.
759
+ const teams = teamsOf(discovery?.standalone === true ? null : workspace, labels);
760
+ return deepFreeze({ ...decl, payloads, payloadOrigins: origins, teams, slotsFrom, declRevision, payloadRevision, revision });
761
+ }
762
+
763
+ /** Where a composed capability came from, as the Desktop names it (`layers.<layer>.from`): the soul's own
764
+ * entry → "soul"; defaults.<slot> or defaults.capabilities → "workspace"; defaults.byTeam.<label> → "team:<label>". */
765
+ export function fromOfVia(via) {
766
+ if (via === "soul") return "soul";
767
+ if (typeof via === "string" && via.startsWith("defaults.byTeam.")) return `team:${via.slice("defaults.byTeam.".length)}`;
768
+ if (typeof via === "string" && via.startsWith("defaults.")) return "workspace";
769
+ return null;
634
770
  }
635
771
 
636
772
  function layerOf(manifest) {