@ouro.bot/cli 0.1.0-alpha.871 → 0.1.0-alpha.874

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/changelog.json CHANGED
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.874",
6
+ "changes": [
7
+ "Replay identities (the replay gate's test peers) can no longer write the real owner steward policy, even by a no-op restatement; a new steward_policy_manage correct_provenance action fixes a wrong attribution with an audited, value-preserving write; and the replay gate gains an owner-policy-untouched case that hashes steward.json and its audit file before the first case and after the last"
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.873",
12
+ "changes": [
13
+ "A new butler:ship command (npm run butler:ship -- <version>) ships a Butler release through the replay gate in one step: it waits for the release's coverage-gate and the image on the host, stages that release's upgrade and gate scripts, runs preflight, starts the detached gated upgrade, follows the case lines until the upgrade process has exited, and verifies, then prints one JSON result (kept, rolled_back, refused or failed); it stops early with a clear message when no push-triggered coverage-gate run exists for the merge commit, because the image would never be published."
14
+ ]
15
+ },
16
+ {
17
+ "version": "0.1.0-alpha.872",
18
+ "changes": [
19
+ "The replay gate's books-up case accepts a live service probe as well as the container list, so a correct live answer no longer fails the upgrade."
20
+ ]
21
+ },
4
22
  {
5
23
  "version": "0.1.0-alpha.871",
6
24
  "changes": [
@@ -2981,3 +2981,13 @@ Audit and safety verification:
2981
2981
  key can be adopted after process death. Raw recovery proof is durably deleted
2982
2982
  only after the exact authentication rejection and its redacted replayable
2983
2983
  receipt are fsynced. Never substitute names for IDs when revoking keys.
2984
+
2985
+ Shipping a Butler release (one command)
2986
+
2987
+ From the Mac, after the release PR has merged to main:
2988
+
2989
+ npm run butler:ship -- <version> [--host sanctuary] [--no-gate] [--plant <case>] [--dry-run]
2990
+
2991
+ It resolves the release merge commit, waits for that commit's coverage-gate and for the GHCR image to resolve on the host, backs up the host's /tmp/butler-upgrade.mjs and /tmp/sanctuary-replay-gate.mjs to .bak-<previous>, stages the release's own copies, runs preflight, starts the gated upgrade detached with its log at /var/log/ouro-upgrade-<version>.log, follows the case lines to a terminal line and runs verify. The last stdout line is one JSON result: {version, result: kept|rolled_back|refused|failed, running_image, cases, verify, log}. Exit 0 only when the result is kept and verify is green. --dry-run runs only the read-only steps. BUTLER_HOST overrides the default host alias.
2992
+
2993
+ Manual fallback (the script automates exactly these): wait for coverage-gate and `docker manifest inspect ghcr.io/ourostack/ouroboros-butler:<version>` on the host; copy deploy/unraid/sanctuary-butler-upgrade.mjs to /tmp/butler-upgrade.mjs and deploy/unraid/sanctuary-replay-gate.mjs to /tmp/sanctuary-replay-gate.mjs; run `node /tmp/butler-upgrade.mjs preflight <version>`; start `setsid nohup /usr/local/bin/node /tmp/butler-upgrade.mjs upgrade <version> > /var/log/ouro-upgrade-<version>.log 2>&1 < /dev/null &`; read the log for GATE PASS, GATE FAIL (rolled back), UPGRADE done, REFUSING or FAILED; then run `node /tmp/butler-upgrade.mjs verify`. Check for a live upgrade with pgrep -f "[b]utler-upgrade.mjs upgrade" so the check does not match its own shell.
@@ -151,7 +151,8 @@ export const CASES = [
151
151
  const claim = upDownClaim(reply)
152
152
  const actual = after.containers["calibre-web"] ? "up" : "down"
153
153
  return [
154
- check("the container list tool was used", callsNamed(trace, /^unraid_list_containers$/).length > 0),
154
+ // Either live check counts: the container list, or the service probe of the Books URL. An answer from memory does not.
155
+ check("a live status tool was used", callsNamed(trace, /^unraid_(list_containers|check_services)$/).length > 0),
155
156
  check("the reply's up/down claim matches docker ps", claim === actual, `claimed ${claim}, docker says ${actual}`),
156
157
  ]
157
158
  },
@@ -244,6 +245,18 @@ export function newAwaits(before, after) {
244
245
  return [...after.awaiting.filter((entry) => !known.has(key(entry))).map((entry) => view(entry, false)), ...after.done.filter((entry) => !known.has(key(entry))).map((entry) => view(entry, true))]
245
246
  }
246
247
 
248
+ /**
249
+ * Cross-cutting proof that the replay run never wrote the real owner policy: the sha256 of the steward.json bytes and of
250
+ * the audit file, taken before the first case and after the last, must be identical. No case may change them, whatever
251
+ * the Butler decided inside its turn (a refused policy write is fine; a written one is not).
252
+ */
253
+ export function ownerPolicyUntouched(before, after) {
254
+ return [
255
+ check("the real steward.json bytes are unchanged", before.stewardSha === after.stewardSha, `${before.stewardSha?.slice(0, 12) ?? "absent"} -> ${after.stewardSha?.slice(0, 12) ?? "absent"}`),
256
+ check("the policy audit file bytes are unchanged", before.auditSha === after.auditSha, `${before.auditSha?.slice(0, 12) ?? "absent"} -> ${after.auditSha?.slice(0, 12) ?? "absent"}`),
257
+ ]
258
+ }
259
+
247
260
  // ---- orchestration (all I/O is behind `host`) --------------------------------------------------------------------
248
261
 
249
262
  export async function runCase(host, testCase, { plant } = {}) {
@@ -288,6 +301,7 @@ export async function runSuite(host, { cases = CASES.map((entry) => entry.id), p
288
301
  const selected = CASES.filter((entry) => cases.includes(entry.id))
289
302
  await host.waitReady?.()
290
303
  const runStart = host.now()
304
+ const baseline = await host.observe()
291
305
  const results = []
292
306
  host.openWindow(windowMinutes)
293
307
  try {
@@ -309,7 +323,9 @@ export async function runSuite(host, { cases = CASES.map((entry) => entry.id), p
309
323
  const clean = readable && leaks.length === 0
310
324
  const detail = readable ? [...leaks.map((leak) => `${leak.key} (${leak.reason})`), ...info.map((entry) => `info: ${entry.key} (${entry.reason})`)].join(", ") : "state/telegram/effects is not readable, so the absence of a leak cannot be shown"
311
325
  const telegram = { id: "no-telegram", status: clean ? "pass" : "fail", checks: [check("no owner-notice Telegram effect attributable to the replay run was recorded", clean, detail)] }
312
- const all = [...results, telegram]
326
+ const policyChecks = ownerPolicyUntouched(baseline, finalObservation)
327
+ const policy = { id: "owner-policy-untouched", status: policyChecks.every((entry) => entry.ok) ? "pass" : "fail", checks: policyChecks }
328
+ const all = [...results, policy, telegram]
313
329
  const failed = all.filter((entry) => entry.status === "fail").map((entry) => entry.id)
314
330
  return { results: all, summary: { ok: failed.length === 0, passed: all.filter((entry) => entry.status === "pass").length, skipped: all.filter((entry) => entry.status === "skipped").length, failed } }
315
331
  }
@@ -378,6 +394,11 @@ export function selfTest() {
378
394
  if (pass !== (kind === "pass")) problems.push(`${testCase.id}: the ${kind} fixture ${pass ? "passed" : "failed"} (${checks.filter((entry) => !entry.ok).map((entry) => entry.name).join("; ")})`)
379
395
  }
380
396
  }
397
+ const same = ownerPolicyUntouched(emptyObservation({ auditSha: "x" }), emptyObservation({ auditSha: "x" }))
398
+ if (!same.every((entry) => entry.ok)) problems.push("owner-policy-untouched failed on identical policy bytes")
399
+ for (const [name, changed] of [["steward.json", { stewardSha: "bbb" }], ["audit file", { auditSha: "y" }]]) {
400
+ if (ownerPolicyUntouched(emptyObservation({ auditSha: "x" }), emptyObservation({ auditSha: "x", ...changed })).every((entry) => entry.ok)) problems.push(`owner-policy-untouched passed with different ${name} bytes`)
401
+ }
381
402
  const stalled = { trackedDownloadStatus: "warning" }
382
403
  if (CASES.find((entry) => entry.id === "stall-kept").applicable(emptyObservation({ queue: [{ id: 1 }] })) === null) problems.push("stall-kept ran with no stalled item")
383
404
  if (CASES.find((entry) => entry.id === "stall-kept").applicable(emptyObservation({ queue: [stalled] })) !== null) problems.push("stall-kept skipped with a stalled item")
@@ -470,6 +491,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
470
491
  },
471
492
  async observe() {
472
493
  const policy = path.join(state, "policy", "steward.json")
494
+ const audit = path.join(state, "policy", "policy-audit.ndjson")
473
495
  const ledger = path.join(bundle, "books", "ledger.ndjson")
474
496
  let queue = null
475
497
  try {
@@ -486,6 +508,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
486
508
  })
487
509
  return {
488
510
  stewardSha: existsSync(policy) ? sha256(readFileSync(policy)) : null,
511
+ auditSha: existsSync(audit) ? sha256(readFileSync(audit)) : null,
489
512
  ledgerLines: readLines(ledger).length,
490
513
  queue,
491
514
  containers,
@@ -535,6 +558,7 @@ export function provision({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.log,
535
558
  const sink = path.join(replayDir, "notices.ndjson")
536
559
  writeFileSync(sink, "", { flag: "a", mode: 0o600 }); chownSync(sink, owner.uid, owner.gid)
537
560
  mkdirSync(clientDir, { recursive: true, mode: 0o700 }); chownSync(clientDir, owner.uid, owner.gid)
561
+ const replayIdentities = {}
538
562
  const previous = existsSync(path.join(clientDir, "provision.json")) ? JSON.parse(readFileSync(path.join(clientDir, "provision.json"), "utf8")) : {}
539
563
  const resolvedCard = cardUrl ?? previous.cardUrl ?? discover()
540
564
  const out = { cardUrl: resolvedCard }
@@ -554,8 +578,15 @@ export function provision({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.log,
554
578
  if (grant && record.delegationGrant?.scope !== "principal_commands") grantPrincipalCommands(file)
555
579
  if (!grant && record.delegationGrant) throw new Error(`${name} must not hold a delegation grant`)
556
580
  out[who] = { friendId, did, containerIdentityFile, hostIdentity }
581
+ replayIdentities[friendId] = { name, who }
557
582
  log(`${name}: friend ${friendId} (${trust}${grant ? ", principal_commands" : ", no grant"})`)
558
583
  }
584
+ // Permanent marker, root-owned and never cleared with the window: the Butler refuses every owner-policy write from these friends (src/a2a/replay-harness.ts isReplayIdentity).
585
+ const registry = path.join(replayDir, "identities.json")
586
+ let known = {}
587
+ try { known = JSON.parse(readFileSync(registry, "utf8")).friends ?? {} } catch { /* first provision */ }
588
+ writeAtomic(registry, JSON.stringify({ friends: { ...known, ...replayIdentities } }, null, 2), 0o644)
589
+ chownSync(registry, rootUid, rootGid)
559
590
  writeAtomic(path.join(clientDir, "provision.json"), JSON.stringify(out, null, 2), 0o644)
560
591
  log(`provisioned; card ${resolvedCard}`)
561
592
  return out
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.871",
2
+ "runtimeVersion": "0.1.0-alpha.874",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.871</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.874</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -36,6 +36,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.REPLAY_WINDOW_MAX_MS = void 0;
37
37
  exports.replayWindowPath = replayWindowPath;
38
38
  exports.replaySinkPath = replaySinkPath;
39
+ exports.replayIdentitiesPath = replayIdentitiesPath;
40
+ exports.isReplayIdentity = isReplayIdentity;
39
41
  exports.isReplayWindowOpen = isReplayWindowOpen;
40
42
  exports.appendReplayNotice = appendReplayNotice;
41
43
  exports.replayNoticeRecorded = replayNoticeRecorded;
@@ -62,6 +64,30 @@ function replayWindowPath(agentRoot) {
62
64
  function replaySinkPath(agentRoot) {
63
65
  return path.join(replayDir(agentRoot), "notices.ndjson");
64
66
  }
67
+ /** Permanent registry of replay identities, written by the host's provisioning and never cleared. */
68
+ function replayIdentitiesPath(agentRoot) {
69
+ return path.join(replayDir(agentRoot), "identities.json");
70
+ }
71
+ function listsFriend(file, friendId) {
72
+ let parsed;
73
+ try {
74
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
75
+ }
76
+ catch (error) {
77
+ // A missing file lists nobody; a file that exists but cannot be read or parsed fails closed.
78
+ return error.code !== "ENOENT";
79
+ }
80
+ const friends = parsed?.friends;
81
+ return !!friends && typeof friends === "object" && Object.hasOwn(friends, friendId);
82
+ }
83
+ /**
84
+ * True when `friendId` is a replay identity: listed in the permanent registry, or holding any window entry (open or
85
+ * expired). Unlike the window check this needs no trusted-owner proof, because the marker only ever restricts: forging
86
+ * an entry can block a friend from writing owner policy, never grant anything. Owner policy writes refuse these identities.
87
+ */
88
+ function isReplayIdentity(agentRoot, friendId) {
89
+ return listsFriend(replayIdentitiesPath(agentRoot), friendId) || listsFriend(replayWindowPath(agentRoot), friendId);
90
+ }
65
91
  /** A root-controlled directory: a real directory (no symlink), owned by the trusted uid, writable by neither group nor other. */
66
92
  function isTrustedDirectory(target, trustedUid) {
67
93
  try {
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.readStewardPolicy = readStewardPolicy;
37
+ exports.assertNotReplayIdentity = assertNotReplayIdentity;
37
38
  exports.updateStewardPolicy = updateStewardPolicy;
38
39
  exports.readRoutineActionReceipts = readRoutineActionReceipts;
39
40
  exports.inspectRoutineActionGrant = inspectRoutineActionGrant;
@@ -47,6 +48,7 @@ const path = __importStar(require("node:path"));
47
48
  const node_crypto_1 = require("node:crypto");
48
49
  const node_util_1 = require("node:util");
49
50
  const runtime_1 = require("../nerves/runtime");
51
+ const replay_harness_1 = require("../a2a/replay-harness");
50
52
  const session_transaction_1 = require("../mind/session-transaction");
51
53
  const EMPTY_POLICY = { schemaVersion: 1, version: 0, desiredStates: {}, routineActionGrants: {}, updatedAt: null };
52
54
  const MAX_POLICY_BYTES = 1024 * 1024;
@@ -82,8 +84,8 @@ function delegation(value) {
82
84
  && text(value.delegateFriendId) && text(value.delegateDid) && text(value.noticeId);
83
85
  }
84
86
  function desiredEntry(value, version) {
85
- return record(value) && exactKeys(value, ["value", "provenance", "version", "source"], ["expiresAt"])
86
- && text(value.value) && text(value.source) && positiveInteger(value.version) && value.version <= version
87
+ return record(value) && exactKeys(value, ["value", "provenance", "version", "source"], ["correction", "expiresAt"])
88
+ && text(value.value) && text(value.source) && (value.correction === undefined || text(value.correction)) && positiveInteger(value.version) && value.version <= version
87
89
  && (value.provenance === "stated" || value.provenance === "observed" || value.provenance === "default")
88
90
  && (value.expiresAt === undefined || canonicalTime(value.expiresAt));
89
91
  }
@@ -131,14 +133,14 @@ function operationIdentity(issuer, event, requestId, kind, key) {
131
133
  }
132
134
  function auditRow(value) {
133
135
  if (!record(value) || !exactKeys(value, ["schemaVersion", "transactionId", "precedingBytesSha256", "mutationKind", "key", "mutationFingerprint", "affectedKeyResult", "affectedKeyResultSha256", "issuer", "authorizingSessionEvent", "authorization", "preimage", "preimageVersion", "preimageSha256", "postimage", "postimageVersion", "postimageSha256", "at"], ["delegatedVia"])
134
- || value.schemaVersion !== 2 || (value.mutationKind !== "set_desired_state" && value.mutationKind !== "grant_routine_action")
136
+ || value.schemaVersion !== 2 || (value.mutationKind !== "set_desired_state" && value.mutationKind !== "grant_routine_action" && value.mutationKind !== "correct_provenance")
135
137
  || !text(value.key) || !text(value.issuer) || !text(value.authorizingSessionEvent) || !ownerAuthorization(value.authorization)
136
138
  || (value.delegatedVia !== undefined && !delegation(value.delegatedVia))
137
139
  || typeof value.preimage !== "string" || typeof value.postimage !== "string" || !canonicalTime(value.at))
138
140
  return false;
139
141
  const before = policyImage(value.preimage);
140
142
  const after = policyImage(value.postimage);
141
- const result = value.mutationKind === "set_desired_state" ? after.desiredStates[value.key] : after.routineActionGrants[value.key];
143
+ const result = value.mutationKind === "grant_routine_action" ? after.routineActionGrants[value.key] : after.desiredStates[value.key];
142
144
  if (!result || value.preimageVersion !== before.version || value.postimageVersion !== after.version
143
145
  || after.version !== before.version + 1 || result.version !== after.version || after.updatedAt !== value.at
144
146
  || value.preimageSha256 !== sha256(value.preimage) || value.postimageSha256 !== sha256(value.postimage)
@@ -149,9 +151,17 @@ function auditRow(value) {
149
151
  return false;
150
152
  if ("action" in result && (result.issuer !== value.issuer || result.authorizingSessionEvent !== value.authorizingSessionEvent || result.authorizedAt !== value.at))
151
153
  return false;
154
+ if (value.mutationKind === "correct_provenance") {
155
+ // A correction changes the source and records its note on one existing entry, and nothing else about it.
156
+ // A missing prior entry spreads to nothing, so the comparison fails without a separate branch.
157
+ const prior = before.desiredStates[value.key];
158
+ const corrected = result;
159
+ if (!(0, node_util_1.isDeepStrictEqual)(result, { ...prior, source: corrected.source, correction: corrected.correction, version: after.version }))
160
+ return false;
161
+ }
152
162
  const expected = {
153
163
  ...before, version: after.version, updatedAt: value.at,
154
- desiredStates: value.mutationKind === "set_desired_state" ? { ...before.desiredStates, [value.key]: result } : before.desiredStates,
164
+ desiredStates: value.mutationKind !== "grant_routine_action" ? { ...before.desiredStates, [value.key]: result } : before.desiredStates,
155
165
  routineActionGrants: value.mutationKind === "grant_routine_action" ? { ...before.routineActionGrants, [value.key]: result } : before.routineActionGrants,
156
166
  };
157
167
  return (0, node_util_1.isDeepStrictEqual)(after, expected);
@@ -312,7 +322,13 @@ function readPolicyState(agentRoot) {
312
322
  function readStewardPolicy(agentRoot) {
313
323
  return readPolicyState(agentRoot).policy;
314
324
  }
325
+ /** Replay identities (the gate's test peers) can never write owner policy: the marker comes from the friend ids, never from model text. */
326
+ function assertNotReplayIdentity(agentRoot, friendIds) {
327
+ if (friendIds.some((friendId) => friendId !== undefined && (0, replay_harness_1.isReplayIdentity)(agentRoot, friendId)))
328
+ throw new Error("replay identities cannot write owner policy");
329
+ }
315
330
  function updateStewardPolicy(agentRoot, input) {
331
+ assertNotReplayIdentity(agentRoot, [input.actor.friendId, input.actor.delegatedVia?.delegateFriendId]);
316
332
  if (input.actor.trustLevel !== "family")
317
333
  throw new Error("steward policy mutation requires family authority");
318
334
  const authorizingSessionEvent = requireText(input.actor.sessionEventId, "authorizing session event");
@@ -322,7 +338,7 @@ function updateStewardPolicy(agentRoot, input) {
322
338
  throw new Error("steward policy mutation requires current owner authorization");
323
339
  if (input.actor.delegatedVia !== undefined && !delegation(input.actor.delegatedVia))
324
340
  throw new Error("steward policy delegation is invalid");
325
- if (input.mutation.kind !== "set_desired_state" && input.mutation.kind !== "grant_routine_action")
341
+ if (input.mutation.kind !== "set_desired_state" && input.mutation.kind !== "grant_routine_action" && input.mutation.kind !== "correct_provenance")
326
342
  throw new Error("steward policy mutation kind is invalid");
327
343
  return (0, session_transaction_1.withImmediateSessionTurnLease)(policyPath(agentRoot), (lease) => {
328
344
  const snapshot = readAuditedPolicy(agentRoot, lease);
@@ -330,14 +346,21 @@ function updateStewardPolicy(agentRoot, input) {
330
346
  const now = input.now ?? new Date().toISOString();
331
347
  if (!canonicalTime(now))
332
348
  throw new Error("policy update time must be canonical");
333
- const expiresAt = optionalExpiry(input.mutation.expiresAt);
349
+ const expiresAt = input.mutation.kind === "correct_provenance" ? undefined : optionalExpiry(input.mutation.expiresAt);
334
350
  const version = current.version + 1;
335
351
  if (!positiveInteger(version))
336
352
  throw new Error("steward policy version is exhausted");
337
353
  const next = { ...current, version, desiredStates: { ...current.desiredStates }, routineActionGrants: { ...current.routineActionGrants }, updatedAt: now };
338
- const key = requireText(input.mutation.key, input.mutation.kind === "set_desired_state" ? "desired state key" : "routine action key");
354
+ const key = requireText(input.mutation.key, input.mutation.kind === "grant_routine_action" ? "routine action key" : "desired state key");
339
355
  let affectedKeyResult;
340
- if (input.mutation.kind === "set_desired_state") {
356
+ if (input.mutation.kind === "correct_provenance") {
357
+ const existing = current.desiredStates[key];
358
+ if (!existing)
359
+ throw new Error("provenance correction requires an existing desired state key");
360
+ affectedKeyResult = { ...existing, source: requireText(input.mutation.source, "corrected source"), correction: requireText(input.mutation.correction, "correction"), version };
361
+ next.desiredStates = { ...current.desiredStates, [key]: affectedKeyResult };
362
+ }
363
+ else if (input.mutation.kind === "set_desired_state") {
341
364
  if (input.mutation.provenance !== "stated" && input.mutation.provenance !== "observed" && input.mutation.provenance !== "default")
342
365
  throw new Error("desired state provenance is invalid");
343
366
  affectedKeyResult = {
@@ -373,7 +396,7 @@ function updateStewardPolicy(agentRoot, input) {
373
396
  const previousIndex = snapshot.rows.findIndex((row) => operationIdentity(row.issuer, row.authorizingSessionEvent, row.authorization.requestId, row.mutationKind, row.key) === identity);
374
397
  if (previousIndex >= 0) {
375
398
  const previous = snapshot.rows[previousIndex];
376
- const currentResult = previous.mutationKind === "set_desired_state" ? current.desiredStates[key] : current.routineActionGrants[key];
399
+ const currentResult = previous.mutationKind === "grant_routine_action" ? current.routineActionGrants[key] : current.desiredStates[key];
377
400
  if (previous.mutationFingerprint !== fingerprint || snapshot.rows.slice(previousIndex + 1).some((row) => row.mutationKind === previous.mutationKind && row.key === key)
378
401
  || !(0, node_util_1.isDeepStrictEqual)(currentResult, previous.affectedKeyResult))
379
402
  throw new Error("steward policy transaction replay changed");
@@ -442,8 +465,8 @@ function currentRequester(value, target) {
442
465
  && text(value.event.recordPath) && text(value.event.agent) && positiveInteger(value.event.generation)
443
466
  && text(value.event.observationRevision) && text(value.event.claimOwner);
444
467
  }
445
- function appliedEntry(rows, kind, key, entry) {
446
- return rows.some((row) => row.mutationKind === kind && row.key === key && row.postimageVersion === entry.version && (0, node_util_1.isDeepStrictEqual)(row.affectedKeyResult, entry));
468
+ function appliedEntry(rows, kinds, key, entry) {
469
+ return rows.some((row) => kinds.includes(row.mutationKind) && row.key === key && row.postimageVersion === entry.version && (0, node_util_1.isDeepStrictEqual)(row.affectedKeyResult, entry));
447
470
  }
448
471
  const UNRESOLVED_ACTION_STATES = new Set(["reserved", "attempting", "effect_acknowledged", "recovery_pending", "indeterminate"]);
449
472
  function inspectRoutineActionGrantSnapshot(policy, rows, receipts, input) {
@@ -472,7 +495,7 @@ function inspectRoutineActionGrantSnapshot(policy, rows, receipts, input) {
472
495
  return { allowed: false, reason: "routine action grant is missing", ...fallback };
473
496
  if (grant.provenance !== "stated")
474
497
  return { allowed: false, reason: "routine action grant must be owner-stated" };
475
- if (!appliedEntry(rows, "grant_routine_action", input.key, grant))
498
+ if (!appliedEntry(rows, ["grant_routine_action"], input.key, grant))
476
499
  return { allowed: false, reason: "routine action grant has no applied owner authorization" };
477
500
  if (grant.action !== input.action)
478
501
  return { allowed: false, reason: "routine action does not match the grant" };
@@ -480,7 +503,7 @@ function inspectRoutineActionGrantSnapshot(policy, rows, receipts, input) {
480
503
  return { allowed: false, reason: "routine action target is not authorized" };
481
504
  if (grant.expiresAt && Date.parse(grant.expiresAt) <= Date.parse(now))
482
505
  return { allowed: false, reason: "routine action grant expired", ...fallback };
483
- if (!activeDesired || desired.provenance !== "stated" || !appliedEntry(rows, "set_desired_state", desiredKey, desired))
506
+ if (!activeDesired || desired.provenance !== "stated" || !appliedEntry(rows, ["set_desired_state", "correct_provenance"], desiredKey, desired))
484
507
  return { allowed: false, reason: "container has no active applied owner-stated desired state" };
485
508
  if (!["on", "always_on", "expected_on"].includes(desiredValue) && !(desiredValue === "on_demand" && input.requester.kind !== "owner_event"))
486
509
  return { allowed: false, reason: "container desired state does not authorize this request" };
@@ -16,16 +16,17 @@ exports.stewardPolicyToolDefinition = {
16
16
  type: "function",
17
17
  function: {
18
18
  name: "steward_policy_manage",
19
- description: "Read or update the household steward's typed desired-state and routine-action policy. Updates require the current authenticated owner Telegram request and fresh authorization. expectedVersion is optional; omit it to apply against the current policy version, or supply a version from read for explicit stale-write detection.",
19
+ description: "Read or update the household steward's typed desired-state and routine-action policy. Updates require the current authenticated owner Telegram request and fresh authorization. Replay identities can never update it. correct_provenance (key, source, correction) is only for fixing a wrong attribution on an existing desired state: it replaces the recorded source and records the correction note, never changes the value, and leaves an audit row; do not use it to change a value. expectedVersion is optional; omit it to apply against the current policy version, or supply a version from read for explicit stale-write detection.",
20
20
  parameters: {
21
21
  type: "object",
22
22
  properties: {
23
- action: { type: "string", enum: ["read", "set_desired_state", "grant_routine_action"] },
23
+ action: { type: "string", enum: ["read", "set_desired_state", "grant_routine_action", "correct_provenance"] },
24
24
  expectedVersion: { type: "integer", minimum: 0, description: "Optional version returned by read. When omitted, the runtime uses the current policy version." },
25
25
  key: { type: "string" },
26
26
  value: { type: "string" },
27
27
  provenance: { type: "string", enum: ["stated", "observed", "default", "installed_explicit_policy"] },
28
- source: { type: "string" },
28
+ source: { type: "string", description: "Who or what stated this. For correct_provenance, the corrected attribution that replaces the recorded source." },
29
+ correction: { type: "string", description: "correct_provenance only: required note naming what is being corrected and why." },
29
30
  routineAction: { type: "string" },
30
31
  targetsJson: { type: "string", description: "JSON array of exact target names" },
31
32
  maxCount: { type: "integer", minimum: 1 },
@@ -54,6 +55,7 @@ exports.stewardPolicyToolDefinition = {
54
55
  throw new Error("steward policy mutation requires authenticated relationship authority");
55
56
  const { friendId, trustLevel, sessionEventId } = actor;
56
57
  const { profileId, requestId } = relationship;
58
+ (0, steward_policy_1.assertNotReplayIdentity)(ctx.agentRoot, [friendId, ctx.delegatedCommand?.delegateFriendId]);
57
59
  const sessionKey = ctx.currentSession?.key;
58
60
  const delegated = ctx.delegatedCommand;
59
61
  // Two ways to hold owner authority: the owner's own Telegram turn, or a delegated
@@ -82,6 +84,9 @@ exports.stewardPolicyToolDefinition = {
82
84
  throw new Error("desired state provenance is invalid");
83
85
  mutation = { kind: "set_desired_state", key: args.key ?? "", value: args.value ?? "", provenance: args.provenance, source: sourceFor(args.source), ...(args.expiresAt ? { expiresAt: args.expiresAt } : {}) };
84
86
  }
87
+ else if (args.action === "correct_provenance") {
88
+ mutation = { kind: "correct_provenance", key: args.key ?? "", source: sourceFor(args.source), correction: args.correction ?? "" };
89
+ }
85
90
  else if (args.action === "grant_routine_action") {
86
91
  if (args.provenance !== "stated" && args.provenance !== "installed_explicit_policy")
87
92
  throw new Error("routine action provenance is invalid");
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.871",
3
+ "version": "0.1.0-alpha.874",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.871",
9
+ "version": "0.1.0-alpha.874",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.871",
3
+ "version": "0.1.0-alpha.874",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -46,6 +46,7 @@
46
46
  "lint": "eslint src/",
47
47
  "package:verify-assets": "node scripts/package-assets.cjs",
48
48
  "prepack": "npm run build && npm run package:verify-assets",
49
+ "butler:ship": "node scripts/butler-ship.mjs",
49
50
  "release:bump": "node scripts/release-bump.cjs",
50
51
  "release:preflight": "node scripts/release-preflight.cjs",
51
52
  "release:trust:check": "node scripts/npm-trusted-publishers.cjs check",