@awebai/oats 0.24.8 → 0.24.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/oats.mjs CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  approveCapability, approveAvailableCapability, updatePackage, removePackage, migrateLegacyLock, applyLegacyLockMigration,
31
31
  packageIntegrity, capabilityArtifactIntegrity, verifyCapabilityInstallation, installedCapabilityDir, installedCapabilitiesDir, ownedCapabilitiesDir, loadPackageManifestAt,
32
32
  resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, planInstanceResources, parseYamlNested, assertSafeConfigValue, assertSafeConfigWriteKey, stripInternalAnnotations, withConfigFile, packagedInject, teamAgentRoots,
33
- findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, findInstanceHomes, listCapabilityAgents, workspaceOf, stopInstanceSession, recomposeInstanceInstructions,
33
+ findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, findInstanceHomes, listCapabilityAgents, workspaceOf, stopInstanceSession, recomposeInstanceInstructions, refreshRetirementBaselineHome,
34
34
  ensureRoot, findRoot, findAgent, listAgents, listInstances, listAgentDefs, createAgent as coreCreateAgent,
35
35
  spawnInstance, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, upsertLocalAgent, defaultRepo, RELATIONS, validateLaunchConfig, resolveLaunchSelection, resolveLaunchExecutable, checkLaunchExecutable, missingLaunchEnvRefs, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_RUNTIMES, LAUNCH_RECIPE_VERSION, parseLaunchCommand, resolveYolo, planLaunch, redactLaunchCommand, restartInstanceSession,
36
36
  } from "../lib/core.mjs";
@@ -600,6 +600,12 @@ function officialMigrationState(legacyLocks, { teamScope, ctx }) {
600
600
  * nearer scope's package of the same id — a provider that never exported it. */
601
601
  const levelRows = (locks, level) => locks.levels.find((l) => l.level === level) || { packages: Object.create(null), capabilities: Object.create(null) };
602
602
 
603
+ /** A capability has an executable surface when its manifest declares commands,
604
+ * hooks or launch environment — the things `oats trust` approves. A
605
+ * data-only capability (skills/injects) has none, and trust is not-applicable. */
606
+ function hasExecutableSurface(manifest) {
607
+ return !!(Object.keys(manifest?.commands || {}).length || Object.keys(manifest?.hooks || {}).length || (manifest?.environment?.length || 0));
608
+ }
603
609
  function capabilityHealth(level, cap, capRow, pkgRow) {
604
610
  const dir = installedCapabilityDir(level, cap.id);
605
611
  if (!cap.installed) return { status: "missing", code: "missing-capability-artifact", dir, detail: `capability ${cap.id} is locked but not materialized — run \`oats install\` to re-materialize it` };
@@ -615,9 +621,7 @@ function capabilityHealth(level, cap, capRow, pkgRow) {
615
621
  try { verifyCapabilityInstallation(dir, cap.id, capRow, pkgRow); }
616
622
  catch (e) { return { status: "provenance-mismatch", code: e.code || "invalid-lock", dir, integrity, detail: `capability ${cap.id}: ${e.message}` }; }
617
623
  }
618
- const executable = Object.keys(cap.manifest?.commands || {}).length
619
- || Object.keys(cap.manifest?.hooks || {}).length
620
- || (cap.manifest?.environment?.length || 0);
624
+ const executable = hasExecutableSurface(cap.manifest);
621
625
  if (executable && !cap.trusted) return { status: "untrusted", code: "untrusted-surface", dir, integrity, detail: `capability ${cap.id}: executable surface UNTRUSTED — \`oats trust ${cap.id}\`` };
622
626
  return { status: "ok", code: null, dir, integrity, detail: null };
623
627
  }
@@ -945,7 +949,7 @@ function computeInspect({ onFail } = {}) {
945
949
  byId.set(c.id, {
946
950
  id: c.id, package: p.package, version: c.version || null, layer: c.manifest?.layer || null, command: c.manifest?.command || null,
947
951
  origin: "installed", level: p.level, source: p.source || null, commit: p.commit ?? rows.packages[p.package]?.commit ?? null, dir: h.dir,
948
- health: { status: h.status, code: h.code, detail: h.detail, installed: !!c.installed, locked: true, trusted: c.trusted === true, integrity: c.integrity || null, installedIntegrity: h.integrity ?? null },
952
+ health: { status: h.status, code: h.code, detail: h.detail, installed: !!c.installed, locked: true, trusted: c.trusted === true, executableSurface: hasExecutableSurface(c.manifest), integrity: c.integrity || null, installedIntegrity: h.integrity ?? null },
949
953
  });
950
954
  }
951
955
  }
@@ -953,13 +957,13 @@ function computeInspect({ onFail } = {}) {
953
957
  for (const [id, m] of Object.entries(mans)) {
954
958
  if (byId.has(id)) continue;
955
959
  const trust = capabilityTrust(m, ctx);
956
- const executable = Object.keys(m.commands || {}).length || Object.keys(m.hooks || {}).length || (m.environment?.length || 0);
960
+ const executable = hasExecutableSurface(m);
957
961
  let integrity = trust.integrity || null;
958
962
  if (!integrity) { try { integrity = capabilityArtifactIntegrity(m._dir); } catch { integrity = null; } }
959
963
  byId.set(id, {
960
964
  id, package: m._package || null, version: m.version || null, layer: m.layer || null, command: m.command || null,
961
965
  origin: String(m._origin || "").split(":")[0] || "unknown", level: String(m._origin || "").split(":").slice(1).join(":") || null, source: null, dir: m._dir,
962
- health: { status: executable && !trust.trusted ? "untrusted" : "ok", code: executable && !trust.trusted ? "untrusted-surface" : null, detail: executable && !trust.trusted ? (trust.reason || null) : null, installed: true, locked: !!trust.lock, trusted: !!trust.trusted, integrity, installedIntegrity: integrity },
966
+ health: { status: executable && !trust.trusted ? "untrusted" : "ok", code: executable && !trust.trusted ? "untrusted-surface" : null, detail: executable && !trust.trusted ? (trust.reason || null) : null, executableSurface: executable, installed: true, locked: !!trust.lock, trusted: !!trust.trusted, integrity, installedIntegrity: integrity },
963
967
  });
964
968
  }
965
969
  // What is EFFECTIVE for the answer: a home's captured bindings and settings
@@ -3215,17 +3219,33 @@ function instanceCmd() {
3215
3219
  bail(e.code || "E_GIT_FAILED", e.message, e.observation ? { observation: e.observation } : undefined);
3216
3220
  }
3217
3221
  }
3218
- /** `oats readiness [--soul <name>] [--home <abs>] [--verify-signatures] [--policy] [--dir <d>] --json` — K5. */
3222
+ /** `oats readiness [--soul <name> [--agents-root <abs>]] [--home <abs>] [--verify-signatures] [--policy] [--dir <d>] --json` — K5. */
3219
3223
  function readinessCmd() {
3220
3224
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
3221
3225
  dropAmbientRoot();
3226
+ // A captured incarnation's readiness comes from its retained resolution, not
3227
+ // from the current configuration this command reads; refuse before inspecting.
3228
+ const homeArg = flag("home");
3229
+ if (homeArg && homeArg !== true) {
3230
+ let capturedMeta = null; try { capturedMeta = JSON.parse(readFileSync(join(String(homeArg), "instance.json"), "utf8")); } catch { /* computeInspect reports the unreadable home */ }
3231
+ if (capturedMeta?.executionBinding || capturedMeta?.captured) return bail("E_UNSUPPORTED_MODE", `${basename(String(homeArg))} is a captured incarnation: its readiness is the retained resolution's, not the current configuration's (inspect it with oats operation --deployment/--resolution)`, { home: String(homeArg), captured: true });
3232
+ }
3222
3233
  const inspect = computeInspect({ onFail: bail });
3223
3234
  if (!inspect) return;
3224
3235
  const soul = flag("soul") === true ? null : flag("soul") || inspect.selected?.soul || null;
3225
3236
  const verify = args.includes("--verify-signatures");
3226
3237
  let catalog = null; try { catalog = describeOfficialCatalog(); catalog = { packages: Object.fromEntries(catalog.packages.map((p) => [p.package, p])) }; } catch { catalog = null; }
3227
3238
  const deploymentDir = inspect.scope?.context ?? null;
3228
- const readiness = readinessOf(inspect, { soul, verifySignatures: verify, catalog, deploymentDir });
3239
+ // Echo the exact selector this read was made with, so a consumer can bind the
3240
+ // result to its own admitted target without inventing a revision.
3241
+ // Every field is the argument AS GIVEN (no realpath): a consumer compares it
3242
+ // byte-exact with what it sent. The canonical scope is subject.context.
3243
+ const given = (name) => { const v = flag(name); return v && v !== true ? String(v) : null; };
3244
+ const agentsRootArg = given("agents-root"), dirArg = given("dir");
3245
+ const selector = homeArg && homeArg !== true ? { kind: "home", home: String(homeArg), soul, agentsRoot: agentsRootArg }
3246
+ : soul ? { kind: "soul", soul, agentsRoot: agentsRootArg, dir: dirArg }
3247
+ : { kind: "scope", dir: dirArg };
3248
+ const readiness = readinessOf(inspect, { soul, verifySignatures: verify, catalog, deploymentDir, selector });
3229
3249
  if (args.includes("--policy")) {
3230
3250
  const homeOpt = flag("home");
3231
3251
  let meta = null;
@@ -4162,7 +4182,21 @@ function spawnCmd() {
4162
4182
  let root;
4163
4183
  try { root = ensureRoot(dirFlag()); }
4164
4184
  catch (e) { bail("E_NO_DEPLOYMENT", e.message || e); throw e; }
4185
+ const isPreview = args.includes("--preview");
4186
+ // --agents-root <abs>: the exact root the soul must live in (as inspect and
4187
+ // readiness take it). With it, no team-soul / capability-agent / importable-
4188
+ // def fallback: the soul is there or the spawn refuses E_SOUL_UNKNOWN.
4189
+ const agentsRootFlag = flag("agents-root");
4190
+ if (agentsRootFlag !== undefined && (agentsRootFlag === true || !isAbsolute(String(agentsRootFlag)))) bail("E_BAD_ARGS", "--agents-root needs an absolute agents root");
4191
+ if (agentsRootFlag !== undefined && realOrResolved(String(agentsRootFlag)) !== realOrResolved(root)) {
4192
+ const teamHit = findTeamAgent(dirFlag(), name), hit = (teamHit?.matches || []).find((m) => realOrResolved(m.root) === realOrResolved(String(agentsRootFlag)));
4193
+ if (!hit) bail("E_SOUL_UNKNOWN", `soul "${name}" is not at agents root ${String(agentsRootFlag)} (this scope's root is ${shortPath(root)})`);
4194
+ root = hit.root;
4195
+ }
4165
4196
  let agent = findAgent(root, name);
4197
+ if (agentsRootFlag !== undefined && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not at agents root ${String(agentsRootFlag)}`);
4198
+ if (isPreview && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not in ${shortPath(root)}; a preview never creates or imports a soul (known: ${listAgents(root).map((a) => a.name).join(", ") || "none"})`);
4199
+ if (isPreview && (flag("instructions-file") !== undefined || flag("def-file") !== undefined)) bail("E_BAD_ARGS", "--preview does not take --instructions-file/--def-file: a preview never writes a soul");
4166
4200
  const instrFile = flag("instructions-file");
4167
4201
  const defFile = flag("def-file");
4168
4202
  if (!agent && !instrFile && !defFile) {
@@ -4285,8 +4319,12 @@ function spawnCmd() {
4285
4319
  // K6: --preview decides everything and touches nothing; --base <ref>
4286
4320
  // selects a worktree's start point; --model @native-default is the
4287
4321
  // explicit "runtime's own default" (distinct from omitting --model).
4288
- ...(args.includes("--preview") ? { preview: true } : {}),
4322
+ ...(args.includes("--preview") ? { preview: true, subject: { soul: name, agentsRoot: agentsRootFlag !== undefined ? String(agentsRootFlag) : null, dir: flag("dir") !== undefined && flag("dir") !== true ? String(flag("dir")) : null } } : {}),
4289
4323
  ...(flag("base") !== undefined && flag("base") !== true ? { baseRef: flag("base") } : {}),
4324
+ // A confirmed preview binds this apply (K6b): drift → E_DECISION_STALE, nothing created.
4325
+ ...(flag("expect-decision") !== undefined && flag("expect-decision") !== true ? { expectDecision: String(flag("expect-decision")) } : {}),
4326
+ // K6c: with --idempotency-key, a retry of the SAME confirmed decision replays the recorded home instead of spawning twice.
4327
+ ...(flag("idempotency-key") !== undefined && flag("idempotency-key") !== true ? { idempotencyKey: String(flag("idempotency-key")) } : {}),
4290
4328
  });
4291
4329
  if (args.includes("--preview")) { if (JSON_MODE) { jsonOk(r); return; } console.log(`preview ${r.agent} → ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch} from ${r.base.ref}@${r.base.oid.slice(0, 12)}` : ""}) runtime ${r.runtime}${r.model ? ` model ${r.model}` : ` (${r.modelSource})`}; nothing was created`); return; }
4292
4330
  } catch (e) {
@@ -4304,14 +4342,26 @@ function spawnCmd() {
4304
4342
  if (e?.code === "E_REQUIREMENT_INACTIVE") { bail(e.code, e.message, { soul: e.soul, capabilities: e.capabilities, context: e.context, remedy: e.remedy }); throw e; }
4305
4343
  if (e?.code === "E_CHILD_SPAWNS_DISABLED") { bail(e.code, e.message, { parent: e.parent, policy: e.policy }); throw e; }
4306
4344
  if (["E_BRANCH_EXISTS", "E_BASE_UNKNOWN"].includes(e?.code)) { bail(e.code, e.message); throw e; }
4345
+ // K6b: the confirmed decision drifted — the fresh decision travels with the refusal so a GUI re-previews.
4346
+ if (e?.code === "E_DECISION_STALE") { bail(e.code, e.message, { decision: e.decision }); throw e; }
4347
+ if (e?.code === "E_IDEMPOTENCY_CONFLICT") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
4348
+ if (e?.code === "E_PLACEMENT_TAKEN") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
4349
+ if (e?.code === "E_SPAWN_INCOMPLETE") { bail(e.code, e.message, { instance: e.instance, home: e.home, launched: e.launched }); throw e; }
4307
4350
  bail(["E_BAD_ARGS", "E_RELATIVE_AMBIGUOUS"].includes(e.code) ? e.code : "E_SPAWN_FAILED", e.message || e); throw e;
4308
4351
  }
4309
4352
  // The instance exists from here on: a failed wake save is reported beside
4310
4353
  // the full receipt, never hidden, and never causes a second spawn.
4311
4354
  let wakeSchedule, wakeScheduleError;
4312
- if (wake) {
4355
+ if (wake && r.replayed !== true) { // a replayed receipt re-saves nothing
4313
4356
  try { wakeSchedule = saveWakeForHome(scheduleScopeOf(workspaceOf(root)), { instance: r.instance, home: r.home, wake }); }
4314
4357
  catch (e) { wakeScheduleError = { code: e.code || "E_SCHEDULE_FAILED", message: e.message }; r.warnings = [...(r.warnings || []), `wake schedule NOT saved: ${e.message}`]; }
4358
+ // K6e: record the wake outcome in the home so a same-key replay can report
4359
+ // it instead of leaving "saved or not?" to inference.
4360
+ if (r.spawnIdempotencyKey) {
4361
+ try { const f = join(r.home, "instance.json"); const m = JSON.parse(readFileSync(f, "utf8")); m.wake = { requested: true, saved: !wakeScheduleError, error: wakeScheduleError ?? null }; writeFileSync(f, JSON.stringify(m, null, 2) + "\n"); r.wake = m.wake; refreshRetirementBaselineHome(r.home); } catch { /* the receipt still says it */ }
4362
+ }
4363
+ } else if (r.spawnIdempotencyKey && r.replayed !== true) {
4364
+ try { const f = join(r.home, "instance.json"); const m = JSON.parse(readFileSync(f, "utf8")); m.wake = { requested: false, saved: null, error: null }; writeFileSync(f, JSON.stringify(m, null, 2) + "\n"); r.wake = m.wake; refreshRetirementBaselineHome(r.home); } catch { /* nothing to record */ }
4315
4365
  }
4316
4366
  if (JSON_MODE) {
4317
4367
  // Desktop CLI API v1 spawn result — a FIXED shape (see docs/desktop-cli-api.md).
@@ -4325,6 +4375,9 @@ function spawnCmd() {
4325
4375
  spawnOrigin: r.spawnOrigin, attach: r.attach,
4326
4376
  ...(r.sessionTarget ? { sessionTarget: r.sessionTarget } : {}),
4327
4377
  ...(r.yolo !== undefined ? { yolo: r.yolo } : {}),
4378
+ // K6b/K6c: what bound this spawn, and whether this receipt is a replay of an earlier one.
4379
+ ...(r.decision ? { decision: r.decision } : {}), ...(r.replayed !== undefined ? { replayed: r.replayed } : {}),
4380
+ ...(r.wake !== undefined ? { wake: r.wake } : {}), // {requested, saved|null, error}: saved:null = outcome not recorded
4328
4381
  launchConfig: r.launch?.launchConfig ?? null, launch: r.launch || null, // already redacted by the kernel
4329
4382
  });
4330
4383
  return;
@@ -4980,7 +5033,7 @@ function versionCmd() {
4980
5033
  // on it (an older CLI without the surface must fail closed with a
4981
5034
  // reason, not an argument error). `features`: kernel abilities a peer
4982
5035
  // must see before relying on them (retire-home: retire --home).
4983
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "catalog", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "schedule-history", "session-recompose"], instanceGitApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 1, eventsApi: 1, scheduleHistoryApi: 2, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
5036
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "catalog", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "schedule-history", "session-recompose", "readiness-verify", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2"], instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 1, scheduleHistoryApi: 2, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
4984
5037
  return;
4985
5038
  }
4986
5039
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -5639,7 +5692,10 @@ Usage:
5639
5692
  oats instance stop <instance> --apply --plan-revision <rev> --idempotency-key <key>
5640
5693
  quiesce (SIGTERM, bounded, never escalated),
5641
5694
  children first; home/work/launch retained
5642
- oats readiness [--soul <n>] [--home <abs>] [--verify-signatures] [--policy] [--json]
5695
+ oats readiness [--soul <n> [--agents-root <abs>]] [--home <abs>] [--verify-signatures] [--policy] [--json]
5696
+ quartet installed|trusted|configured|enrolled for a scope,
5697
+ a soul, or an instance home (captured homes refuse:
5698
+ their readiness is the retained resolution's)
5643
5699
  installed | trusted | configured | enrolled, each
5644
5700
  pass|fail|unknown|not-applicable with items and
5645
5701
  remedies; signature status per artifact; enforced
@@ -2,7 +2,7 @@
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { fs, join, dirname, resolve, readJSON, save, safePath, cliPath, oats, fail, unlock } from '../lib/io.mjs';
4
4
  import { loadBindings, declaration, splitRef } from '../lib/config.mjs';
5
- import { register, registerCaptured, loadInvocationSourceReceipt, homeSource, loadSource, loadStatus, saveStatus, updateStatus, capture, scheduleSource, service, markerPath, views } from '../lib/sources.mjs';
5
+ import { register, registerCaptured, loadInvocationSourceReceipt, homeSource, loadSource, loadStatus, saveStatus, updateStatus, capture, scheduleSource, settleRetiredSchedule, service, markerPath, views } from '../lib/sources.mjs';
6
6
  import { runSource, complete, retry, readRun, requireQualifiedHelper } from '../lib/worker.mjs';
7
7
  import { initBase, migrate, deliverMigration, cutoverMigration, migrateSource } from '../lib/migration.mjs';
8
8
  import { inspect } from '../lib/inspection.mjs';
@@ -121,13 +121,13 @@ else {
121
121
  let s;
122
122
  if(sourceReceipt.mode==='captured') {s=registerCaptured(home,sourceReceipt.receipt);if(s.skipped) {result={meta:{retired:true,reason:'service'}};s=null;}}
123
123
  else {if(!fs.existsSync(markerPath(home))) fail('E_MIGRATION','captured retire requires a durable registered source or explicit helper receipt');s=src();}
124
- if(s) {scheduleSource(s);const r=capture(s,{final:true});result={meta:{retired:r.complete===true,source:s.file,capture:r},brief:'Final input is in durable custody. Delivery remains asynchronous.'};}
124
+ if(s) {scheduleSource(s);const r=capture(s,{final:true});const schedule=settleRetiredSchedule(s);result={meta:{retired:r.complete===true,source:s.file,capture:r,schedule},brief:'Final input is in durable custody. Delivery remains asynchronous.'};}
125
125
  } else if(service(home)) result={meta:{retired:true}};
126
126
  else if(!fs.existsSync(markerPath(home))) {
127
127
  if(['STATE.md','log.md','notes','.okf-harvest-record.json','.okf-harvest-record.next.json'].some(p=>fs.existsSync(join(home,p)))) fail('E_MIGRATION','unregistered/legacy source has memory; explicitly migrate/register before retirement');
128
128
  result={meta:{retired:true,reason:'nothing-to-delete'}};
129
129
  } else {
130
- const s=src();scheduleSource(s);const r=capture(s,{final:true});result={meta:{retired:r.complete===true,source:s.file,capture:r},brief:'Final input is in durable custody. Delivery remains asynchronous.'};
130
+ const s=src();scheduleSource(s);const r=capture(s,{final:true});const schedule=settleRetiredSchedule(s);result={meta:{retired:r.complete===true,source:s.file,capture:r,schedule},brief:'Final input is in durable custody. Delivery remains asynchronous.'};
131
131
  }
132
132
  } else if(event==='harvest') {
133
133
  if(capturedHarvest) {
@@ -245,7 +245,27 @@ export function loadInvocationKnowledgeBinding(env=process.env) {
245
245
  let runtime;try{runtime=sourceRuntimeFromKnowledgeBinding(binding);}catch{invocationError();}
246
246
  return {kind:'captured',file,binding,runtime};
247
247
  }
248
- function problem(code) {return {code};}
248
+ // Closed vocabulary of check-phase reasons: one fixed literal per cause, so the
249
+ // operator learns WHICH qualification failed without any value, path, alias or
250
+ // caught message reaching the wire. Every literal is also in oats.json
251
+ // binding.reasons (byte-exact) — the manifest test pins that.
252
+ const checkReasons=Object.freeze({
253
+ 'action:not-admitted':'check action is not an admitted knowledge operation',
254
+ 'bindings:invalid':'bound runtime bindings file is missing or invalid',
255
+ 'bases:too-many':'more than 64 git knowledge bases declared',
256
+ 'base:stage-failed':'declared knowledge base could not be staged from its git source',
257
+ 'base:not-validated':'declared knowledge base is not a validated knowledge tree',
258
+ 'base:owner-unmet':'knowledge base owner or remote custody requirement not met',
259
+ 'base:source-mismatch':'staged git source does not match the declared knowledge base',
260
+ 'runtime:command-missing':'harvest runtime command is not installed on this host',
261
+ 'runtime:not-qualified':'harvest runtime could not be qualified against the accepted bases',
262
+ });
263
+ function problem(code,reason) {
264
+ if(reason===undefined) return {code};
265
+ if(!Object.hasOwn(checkReasons,reason)) wireError('invalid-binding');
266
+ return {code,message:checkReasons[reason]};
267
+ }
268
+ export const CHECK_REASONS=Object.values(checkReasons);
249
269
  function providerActionName(action) {
250
270
  if(action.kind==='operation' && action.slot===SLOT) return action.name;
251
271
  if(action.kind!=='command') return null;
@@ -261,19 +281,26 @@ function checkPhase(req) {
261
281
  const harvestInvocation=req.input.invocation;
262
282
  const admittedHarvest=name==='harvest' && action.kind==='operation' && action.slot==='knowledge' && action.name==='harvest'
263
283
  && harvestInvocation?.subject.kind==='persistent' && harvestInvocation.instance!==null && !!harvestInvocation.intent;
264
- if(name && (unsupportedCapturedCommands.has(name) || name==='run-source' || (name==='harvest' && !admittedHarvest))) return {status:'needs-configuration',problems:[problem('provider-not-qualified')]};
284
+ if(name && (unsupportedCapturedCommands.has(name) || name==='run-source' || (name==='harvest' && !admittedHarvest))) return {status:'needs-configuration',problems:[problem('provider-not-qualified','action:not-admitted')]};
265
285
  if(action.kind==='hook' && action.name==='soul-scaffold') return {status:'ready',problems:[]};
266
- let bindings;try{bindings=validateBindings(runtime.bindings,runtime.descriptorFile);}catch{return {status:'needs-configuration',problems:[problem('needs-configuration')]};}
286
+ let bindings;try{bindings=validateBindings(runtime.bindings,runtime.descriptorFile);}catch{return {status:'needs-configuration',problems:[problem('needs-configuration','bindings:invalid')]};}
267
287
  const accepted={},gitBases=Object.entries(bindings.bases).filter(([,base])=>base.kind==='git');
268
- if(gitBases.length>64) return {status:'unavailable',problems:[problem('provider-not-qualified')]};
269
- let scratch=null;
288
+ if(gitBases.length>64) return {status:'unavailable',problems:[problem('provider-not-qualified','bases:too-many')]};
289
+ let scratch=null,stage='base';
270
290
  try {
271
291
  if(gitBases.length) scratch=fs.mkdtempSync(join(fs.realpathSync(tmpdir()),'oats-okf-binding-check-'));
272
- for(const [alias,base] of Object.entries(bindings.bases)) accepted[alias]=(base.kind==='directory'?validateBase(base.path,base):stageBase(base,join(scratch,alias))).meta;
292
+ for(const [alias,base] of Object.entries(bindings.bases)) {
293
+ stage=base.kind==='directory'?'validate':'stage';
294
+ accepted[alias]=(base.kind==='directory'?validateBase(base.path,base):stageBase(base,join(scratch,alias))).meta;
295
+ }
296
+ stage='runtime';
273
297
  checkKnowledgeRuntime({rendered:runtime,accepted});
274
298
  } catch(error) {
275
- if(error.code==='E_COMMAND') return {status:'unavailable',problems:[problem('provider-unavailable')]};
276
- if(['E_OWNER','E_BASE','E_VALIDATION','E_DIRECTORY_GIT','E_CONFIRM'].includes(error.code)) return {status:'needs-configuration',problems:[problem('provider-not-qualified')]};
299
+ if(error.code==='E_COMMAND') return {status:'unavailable',problems:[problem('provider-unavailable','runtime:command-missing')]};
300
+ if(error.code==='E_OWNER') return {status:'needs-configuration',problems:[problem('provider-not-qualified','base:owner-unmet')]};
301
+ if(error.code==='E_CONFIRM') return {status:'needs-configuration',problems:[problem('provider-not-qualified','base:source-mismatch')]};
302
+ if(['E_BASE','E_VALIDATION','E_DIRECTORY_GIT'].includes(error.code)) return {status:'needs-configuration',problems:[problem('provider-not-qualified',stage==='stage'?'base:stage-failed':'base:not-validated')]};
303
+ if(stage==='runtime') return {status:'unavailable',problems:[problem('provider-unavailable','runtime:not-qualified')]};
277
304
  return {status:'unavailable',problems:[problem('provider-unavailable')]};
278
305
  } finally {
279
306
  if(scratch) fs.rmSync(scratch,{recursive:true,force:true});
@@ -67,6 +67,7 @@ export function loadBindings(file = settings()['bindings-file'], opts = {}) {
67
67
  export function declaration(soul) {
68
68
  if(fs.existsSync(join(soul,'.okf-cutover.json'))) fail('E_MIGRATION','incomplete explicit migration cutover: rerun its recorded migrate --cutover command');
69
69
  if(fs.existsSync(join(soul,'knowledge'))) fail('E_MIGRATION','legacy soul/knowledge exists: use oats okf migrate to preserve and stage it, then explicit cutover; no automatic loss');
70
+ if(!fs.existsSync(join(soul,'okf.json'))) fail('E_CONFIG',`soul has no okf.json: this soul reads/owns no knowledge yet. Provision it explicitly (oats okf init, or oats okf migrate for a legacy soul), or deactivate oats.okf for this soul; nothing was created`);
70
71
  return validateDeclaration(readJSON(join(soul,'okf.json')));
71
72
  }
72
73
  export function validateDeclaration(d) {
@@ -246,6 +246,21 @@ export function input(source,id) {
246
246
  const value=readJSON(join(dirname(source.file),'inputs',`${id}.json`));
247
247
  if(hash(value)!==id) fail('E_INPUT','durable evidence hash mismatch');return value;
248
248
  }
249
+ /** Switch a retired source's job off once nothing is pending. Idempotent; a
250
+ * scheduler failure is recorded, never thrown — the evidence is already safe. */
251
+ export function settleRetiredSchedule(source) {
252
+ const status=loadStatus(source);
253
+ if(status.schedule?.settled===true) return {status:'already-disabled',id:status.schedule.id};
254
+ if(!status.retired || status.auto || status.schedule?.id===undefined) return {status:'kept'};
255
+ try {
256
+ oats(['schedule','disable',status.schedule.id,'--dir',source.context,'--json'],source.context);
257
+ updateStatus(source,current=>{current.schedule={...current.schedule,settled:true,settledAt:new Date().toISOString()};});
258
+ return {status:'disabled',id:status.schedule.id};
259
+ } catch(e) {
260
+ updateStatus(source,current=>{current.schedule={...current.schedule,settled:false,settleError:e.message};});
261
+ return {status:'disable-failed',id:status.schedule.id};
262
+ }
263
+ }
249
264
  export function capture(source,{final=false,deadlineMs=85000}={}) {
250
265
  return withLock(join(dirname(source.file),'capture.lock'),()=>{
251
266
  const status=loadStatus(source);
@@ -318,7 +333,15 @@ export function capture(source,{final=false,deadlineMs=85000}={}) {
318
333
  }
319
334
  status.lastCapture={status:report.status,complete:report.complete===true,ignored:report.ignored||0,at:new Date().toISOString()};
320
335
  if(report.complete!==true) fail('E_CAPTURE',`capture ${report.status || 'uncertified'}: retain source and retry`);
321
- if(final) {status.retired=true;status.auto=status.auto && !noLaunch;status.retiredAt=new Date().toISOString();}
336
+ if(final) {
337
+ status.retired=true;status.retiredAt=new Date().toISOString();
338
+ // A retired source whose every captured input is already processed has
339
+ // no further work: its schedule is switched off now (never deleted —
340
+ // the job definition stays as evidence). Anything still pending keeps
341
+ // the job enabled until the worker drains it (see worker.mjs).
342
+ const drained=status.captured.inputs.every(id=>status.processed.includes(id));
343
+ status.auto=status.auto && !noLaunch && !drained;
344
+ }
322
345
  saveCapture(source,status);return {...status.lastCapture,inputs:status.captured.inputs.length};
323
346
  } catch(e) {
324
347
  status.lastCapture={status:'incomplete',complete:false,error:e.message,at:new Date().toISOString()}; saveCapture(source,status);throw e;
@@ -351,7 +374,9 @@ export function scheduleSource(source) {
351
374
  if(!sameJson(responsible,spec.responsibleHuman)) fail('E_SCHEDULE','captured source schedule responsible human differs');
352
375
  if(actual.execution && (actual.execution.deployment!==source.executionBinding.deployment || actual.execution.resolution?.id!==source.executionBinding.resolution.id)) fail('E_SCHEDULE','captured source schedule execution binding differs');
353
376
  }
354
- updateStatus(source,status=>{status.schedule={id:spec.id,status:'ready',result};});return result;
377
+ // A job already settled off for a retired, drained source stays settled:
378
+ // registration re-verifies the definition but does not forget the switch-off.
379
+ updateStatus(source,status=>{const settled=status.schedule?.settled===true?{settled:true,settledAt:status.schedule.settledAt}:{};status.schedule={id:spec.id,status:'ready',result,...settled};});return result;
355
380
  } catch(e) {
356
381
  updateStatus(source,status=>{status.schedule={...(status.schedule || {}),id:spec.id,status:'failed',error:e.message};});throw e;
357
382
  }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { isAbsolute, resolve } from 'node:path';
3
3
  import { fs, join, dirname, safePath, readJSON, save, atomic, tree, materialize, digest, hash, withLock, oats, command, fail, relPath } from './io.mjs';
4
- import { loadSource, loadStatus, saveStatus, updateStatus, capture, input, markerPath, homeSource } from './sources.mjs';
4
+ import { loadSource, loadStatus, saveStatus, updateStatus, capture, input, markerPath, homeSource, settleRetiredSchedule } from './sources.mjs';
5
5
  import {capturedSource,qualifyCapturedWorker,assertCapturedRun,capturedScaffold,retainCapturedWorkerCustody,assertCapturedWorkerHome,capturedStart} from './captured-worker.mjs';
6
6
  import { metadata, splitRef } from './config.mjs';
7
7
  import { stageBase, validateBase, allowedChanges, verifyGitScope, gitPublish, directoryPublish, journalPath, baseLock, recoveryStage, reconcileDirectoryIntent, gitRecoveryState } from './stores.mjs';
@@ -63,7 +63,10 @@ export function runSource(source,{noLaunch=false,manual=false,capturedInvocation
63
63
  const previous=status.pendingRejudgment?readRun(source,status.pendingRejudgment):null;
64
64
  if(previous) checkRecoveryGuards(source,previous);
65
65
  const ids=previous?previous.inputs:status.captured.inputs.filter(id=>!status.processed.includes(id));
66
- if(!ids.length) return status.finalCaptureUncertified?{status:'source-unavailable',processedCapturedInput:true,finalCaptureComplete:false}:{status:'empty',processed:true};
66
+ if(!ids.length) {
67
+ if(status.retired && !status.finalCaptureUncertified) {updateStatus(source,current=>{current.auto=false;});settleRetiredSchedule(source);}
68
+ return status.finalCaptureUncertified?{status:'source-unavailable',processedCapturedInput:true,finalCaptureComplete:false}:{status:'empty',processed:true};
69
+ }
67
70
  const selected=[];let bytes=0;
68
71
  for(const id of ids) {const n=Buffer.byteLength(JSON.stringify(input(source,id)));if(selected.length && bytes+n>192000) break;selected.push(id);bytes+=n;}
69
72
  if(!source.decl.owns.length) fail('E_OWNER','source has evidence but owns no destination; retained for explicit ownership routing');
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "capability": "oats.okf",
3
3
  "command": "okf",
4
- "version": "2.1.2",
4
+ "version": "2.1.3",
5
5
  "compatibility": {
6
6
  "oats": ">=0.24.4"
7
7
  },
@@ -62,7 +62,16 @@
62
62
  "setting state-dir must be a normalized absolute host path",
63
63
  "setting harvest-runtime is required (pi, claude or codex)",
64
64
  "setting harvest-runtime must be pi, claude or codex",
65
- "setting harvest-model must be null or a non-empty string"
65
+ "setting harvest-model must be null or a non-empty string",
66
+ "check action is not an admitted knowledge operation",
67
+ "bound runtime bindings file is missing or invalid",
68
+ "more than 64 git knowledge bases declared",
69
+ "declared knowledge base could not be staged from its git source",
70
+ "declared knowledge base is not a validated knowledge tree",
71
+ "knowledge base owner or remote custody requirement not met",
72
+ "staged git source does not match the declared knowledge base",
73
+ "harvest runtime command is not installed on this host",
74
+ "harvest runtime could not be qualified against the accepted bases"
66
75
  ]
67
76
  },
68
77
  "inject": "injects/okf.md",
@@ -76,13 +85,17 @@
76
85
  "command": "bin/oats-okf.mjs spawn",
77
86
  "required": true,
78
87
  "inputs": {
79
- "sourceReceipt": { "version": 1 }
88
+ "sourceReceipt": {
89
+ "version": 1
90
+ }
80
91
  }
81
92
  },
82
93
  "retire": {
83
94
  "command": "bin/oats-okf.mjs retire",
84
95
  "inputs": {
85
- "sourceReceipt": { "version": 1 }
96
+ "sourceReceipt": {
97
+ "version": 1
98
+ }
86
99
  }
87
100
  }
88
101
  },
@@ -99,8 +112,18 @@
99
112
  "context": "home",
100
113
  "description": "Capture this source into durable custody and request an independent worker.",
101
114
  "args": [
102
- {"name": "native-request", "flag": "--native-request", "required": false, "description": "Captured operations require an explicit absolute backend-only native request JSON; provider owns the worker task."},
103
- {"name": "worker-mode", "flag": "--worker-mode", "required": false, "description": "Captured worker mode: prepare or launch (default launch); legacy behavior is unchanged when absent."}
115
+ {
116
+ "name": "native-request",
117
+ "flag": "--native-request",
118
+ "required": false,
119
+ "description": "Captured operations require an explicit absolute backend-only native request JSON; provider owns the worker task."
120
+ },
121
+ {
122
+ "name": "worker-mode",
123
+ "flag": "--worker-mode",
124
+ "required": false,
125
+ "description": "Captured worker mode: prepare or launch (default launch); legacy behavior is unchanged when absent."
126
+ }
104
127
  ]
105
128
  }
106
129
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Purpose:** the one accurate view of every work stream in the redesign, what is on main, what is in flight, who owns it, and what blocks it. Lead: `oats-expert` (redesign lead). Updated whenever anything merges, is returned, or reality changes. Older per-lane boards are superseded by this file.
4
4
 
5
- **Last update:** 2026-09-22 10:30Z (clock verified with `date -u`; the previous "22:20Z" stamp was a wall-clock error) · **Slice 2b MERGED** (PR65) · **K3 producer pins closed** (PR66 advertisement + guarded Remove; PR67 kernel-owned child stop, confirmed-branch binding, per-key stop replay, ambiguous parentage) · `oats session recompose` seam (in-place instruction refresh) · engineer wiring **2c** on `aed94b74` → 5 → 6b → 7b → 8 · lead: reviews; tag 0.24.8 when 2c lands
5
+ **Last update:** 2026-09-22 19:10Z · **v0.24.9 PUBLISHED** (tag `04a4f709`; both packages on npm, tarball shasum `6333c01c`; bump PR79 → main `a5cc390e`; artifact probe: readiness/preview API 2/bound apply/E_DECISION_STALE/retire retention/no-git verify all pass, caller alive, soul intact) · **6b READ MERGED** (PR78) · K6b ✅ · PR77 ✅ · engineer → **6b APPLY companion proposal** → 7b → 8 · open contracts: K11, attach-knowledge, auto-PR, branch enumeration
6
6
 
7
7
  Legend: ✅ on main/published · 🔄 in flight (PR/branch) · 🟡 preserved, not adopted · ⬜ not started · ⛔ blocked
8
8
 
@@ -239,6 +239,92 @@ executable, runtime packages, child-spawn policy) and returns what the spawn
239
239
  knowledge provider (05 excluded, attach stays), auto-PR intent (ADE-owned,
240
240
  P1).
241
241
 
242
+ ### Preview API 2 (0.24.9+, feature `spawn-preview-2`) — the safe-mode fence
243
+
244
+ **API 1 previews wrote before they returned** (a refused child spawn appended an
245
+ event to the parent; a Herdr backend could be started; an unknown soul could be
246
+ imported from an importable def). A consumer must therefore gate on
247
+ **`spawnPreviewApi === 2` AND `features.includes("spawn-preview-2")`** — API 1 is
248
+ the pre-fix marker and is never accepted for dispatch.
249
+
250
+ - **No writes, success or refusal.** A preview appends no event, starts no
251
+ daemon (`backendStatus {name, installed, started:false}` reports what it
252
+ observed), and never creates/updates a soul (`E_SOUL_UNKNOWN` instead of an
253
+ import; `--instructions-file`/`--def-file` refused with `E_BAD_ARGS`). Test:
254
+ the deployment tree is byte-identical after a success, a refusal and an
255
+ unknown-soul preview.
256
+ - **Exact root**: `spawn <soul> --agents-root <abs>` binds the soul to that root
257
+ (as inspect/readiness take it) — no team-soul / capability-agent / importable-
258
+ def fallback; mismatch → `E_SOUL_UNKNOWN`. The preview echoes
259
+ `subject {soul, agentsRoot|null, dir|null}` **as given, byte-exact**.
260
+ - **Decision binding**: `decision {instance, home, branch, base{ref,oid},
261
+ revision}` (24-hex). Apply with `spawn … --expect-decision <revision>`: the
262
+ kernel recomputes name/home/branch/base under the same placement path and
263
+ refuses **`E_DECISION_STALE`** with `details.decision` (the fresh one) on ANY
264
+ drift — no auto-suffix, no silent re-base, nothing created. A GUI re-previews
265
+ and re-confirms; it never second-guesses names or paths. Without the flag the
266
+ CLI keeps its legacy auto-suffix for humans.
267
+ - **Bounded preflight**: every native probe a preview runs (`pi --list-models`,
268
+ `pi list`, `claude plugin list`) shares ONE budget (20 s default), runs in its
269
+ own process group and is group-killed on timeout; `preflight {status:
270
+ complete|timeout, budgetMs, elapsedMs}` says which. A hanging runtime cannot
271
+ hang a preview.
272
+ - **Confirmed apply contract** (0.24.10+, feature `spawn-apply-2`,
273
+ `spawnApplyApi: 1`) — what a GUI may promise at "Confirm spawn":
274
+ - `decision` gains **`effective {repo, work, runtime, model, launchConfig,
275
+ yolo, backend, childSpawns, relation{kind, anchor{instance, agentsRoot}}}`**
276
+ and `revision` hashes placement + effective. An inherited default that would
277
+ change what launches (the soul's model edited between preview and apply,
278
+ say) → `E_DECISION_STALE`. A GUI does not re-resolve anything itself.
279
+ - **No effect before the fence**: backend presence and `ensureHerdr` run only
280
+ AFTER a successful `--expect-decision` binding and after the placement
281
+ reservation. A stale apply with `--backend herdr` starts nothing. (The
282
+ parent-policy refusal still appends `child-spawn-refused` to the PARENT's
283
+ log on a non-preview apply — that is an audit of a real refusal, not an
284
+ effect on the target.)
285
+ - **Exclusive placement**: the home is reserved with a non-recursive `mkdir`
286
+ immediately after the decision check; a concurrent spawn that lost refuses
287
+ **`E_PLACEMENT_TAKEN`** having touched nothing. Two concurrent applies of
288
+ one decision yield exactly one home. There is no wider lock; this
289
+ reservation is the guarantee.
290
+ - Gate confirmation AND the exec owner on `spawn-preview-2` +
291
+ `spawn-apply-2` + `spawn-idempotency`; a legacy local request on such a CLI
292
+ is refused by the Desktop (`E_PLAN_REQUIRED`), not routed around the fence.
293
+ - **Replay custody** (0.24.10+, feature **`spawn-idempotency-2`** — gate on
294
+ this, not on `spawn-idempotency`, whose replay could be blocked by
295
+ `E_BRANCH_EXISTS`): key recovery runs **first**, right after the name is
296
+ decided and before any placement/branch/base/preflight/backend work — so a
297
+ retry of a spawn that created its explicit branch still reaches its receipt.
298
+ The key-bearing home records `spawnCompleted:false` at its first write and
299
+ `true` only after launch + lineage + final events; a same-key retry of an
300
+ unfinished spawn refuses **`E_SPAWN_INCOMPLETE`** (`details.{instance, home,
301
+ launched}`; remedy is the session surface, never another spawn). The key
302
+ lives in the home by design: durable across the GUI's restart, gone with a
303
+ retired home — after a retire, "check result" is a roster question. The wake
304
+ outcome is recorded (`wake {requested, saved, error}`) and returned on
305
+ replay; `saved:null` means *not recorded* (crash in the interval) — render
306
+ "Agent created; wake outcome unavailable — check Schedules", never
307
+ saved/not-saved without the record.
308
+ - **Retention stays clean**: the completion marker and the wake record are
309
+ kernel writes to `instance.json` made after the spawn's retirement baseline;
310
+ the kernel re-stamps the baseline's home fingerprint after each, so a fresh
311
+ keyed home retires with **no** `changed instance-home bytes` — only the
312
+ agent's own changes ever read as work to recover.
313
+ - **Idempotent apply** (0.24.10+, feature `spawn-idempotency`): `spawn …
314
+ --expect-decision <rev> --idempotency-key <key>` records the key and the
315
+ decision in the new home's `instance.json`; a **retry with the same key**
316
+ replays the recorded receipt (`replayed: true`, same instance/home, no second
317
+ spawn, no wake re-saved) — found by key across the soul's instances, never by
318
+ name (the planned name may have been auto-suffixed past it, which is exactly
319
+ the retry case). The same key with a *different* decision refuses
320
+ **`E_IDEMPOTENCY_CONFLICT`** (`details.instance/home` of the prior spawn); a
321
+ different key with a fresh decision is a genuinely new confirmation. Mint the
322
+ key server-side on the first confirmation and keep it for that intent's
323
+ retries (as 2c does); a lost response is a replay, never a guess by name.
324
+ - Still absent (named follow-ups, not parity-done): attach-knowledge node refs
325
+ (provider contract), auto-PR (P1/ADE write approval), branch enumeration
326
+ (producer seam).
327
+
242
328
  ## Readiness quartet, signatures, enforced policy (`oats readiness`, `readinessApi: 1`, OATS 0.24.8+)
243
329
 
244
330
  `oats readiness [--soul <name>] [--home <abs>] [--verify-signatures] [--policy] [--dir <d>] --json`
@@ -298,6 +384,58 @@ instance's recorded (enforced) one; with only `--soul` it is the declaration
298
384
  (`enforced: false`). It is a lifecycle-authority claim, not an OS sandbox —
299
385
  the UI says so.
300
386
 
387
+ ### Slice-5 producer pins (0.24.9+; all additive — gate items on field presence)
388
+
389
+ - **`configured` is EFFECTIVE activation.** `activation.enabled` is the resolved
390
+ verdict for the subject; a capability *declared* for the soul but disabled is
391
+ `fail` with reason `declared for soul <n> but disabled (…)`. Declaration is
392
+ never activation.
393
+ - **Trust is not-applicable for data-only capabilities.** The inspect row now
394
+ carries `health.executableSurface` (manifest commands/hooks/launch env — what
395
+ `oats trust` approves). No surface → `trusted` item `not-applicable`, reason
396
+ `no executable surface`, whatever the lock records. This is why a fresh
397
+ `oats trust <package> --all-capabilities` "skipped" `oats.core` and readiness
398
+ still said fail before 0.24.9.
399
+ - **Typed linkage on every item**: `capability {id, level, scope}` and
400
+ `origin {kind: requires|declares|default|inventory, target}`; plus
401
+ `summary.byCapability[] {capability, origin, required, checks{installed,
402
+ trusted, configured, enrolled}, ownReady, ready}` — the SAME items regrouped,
403
+ no second observation. `ownReady` is the capability's own four verdicts;
404
+ `ready` is `ownReady` AND no **subject-level blocker** — items that belong to
405
+ no capability (workspace membership, soul declarations) are listed in
406
+ `summary.subjectBlockers[] {check, subject, status}` and block every row.
407
+ A per-capability row never says ready while the subject is blocked, and a
408
+ row's verdict is never promoted to the subject's `summary.ready`. Render
409
+ per-capability rows from this; never parse subjects.
410
+ - **Selector echo**: `subject.selector` = the arguments **as given, byte-exact,
411
+ no realpath** — `{kind:"scope", dir|null}` · `{kind:"soul", soul,
412
+ agentsRoot|null, dir|null}` · `{kind:"home", home, soul, agentsRoot|null}`.
413
+ Compare with what you sent, byte for byte; never filesystem-normalize a
414
+ response path. The canonical scope is `subject.context` (may differ from
415
+ `dir`, e.g. `/var` vs `/private/var` on macOS).
416
+ - **Unreadable member document** (`oats.yaml` unreadable, or `workspace:`
417
+ present but not a mapping) → `enrolled` item `unknown` with
418
+ `evidence.file`, never `not-applicable`. A declared backlink stays `unknown`
419
+ with reason `reciprocal admission not observed …` until the CLI fetches the
420
+ workspace's members (K11).
421
+ - **Captured homes refuse**: `readiness --home <captured>` →
422
+ `E_UNSUPPORTED_MODE` (`details.captured: true`) before any current-config
423
+ interpretation.
424
+ - **`--agents-root <abs>`** is accepted with `--soul` (and with `--home`), as
425
+ inspect takes it — pin the exact root you admitted.
426
+ - **Signature verification (feature `readiness-verify`)**: `--verify-signatures`
427
+ is bounded custody — **one total budget per readiness read** (120 s default)
428
+ shared by every capability's fetch and verify (an exhausted budget refuses the
429
+ remaining capabilities with `budget-exhausted`, no fetch), each Git child in
430
+ its own process group and the **whole group** SIGKILLed on timeout or failure,
431
+ scratch repositories removed on normal exit and on SIGINT/SIGTERM/SIGHUP, `GIT_CONFIG_GLOBAL
432
+ =/dev/null` + no system config + no prompts/askpass, **only https/ssh**
433
+ transports. `signature.failure` is `null` or `{code}` from the closed set
434
+ `transport-not-allowed | fetch-failed | fetch-timeout | budget-exhausted |
435
+ verifier-failed | verifier-timeout | cannot-check`; `signature.reason` is a
436
+ fixed sentence, **never stderr**. Gate the *Verify signatures…* action on the
437
+ feature name; keep it an explicit user action.
438
+
301
439
  ## Lifecycle plans — Stop and Remove (`lifecycleApi: 1`, OATS 0.24.8+)
302
440
 
303
441
  The Desktop's Stop and Remove confirmations render **plans**: a read-only