{"version":3,"file":"approval.cjs","names":[],"sources":["../../../src/batteries/orchestration/approval.ts"],"sourcesContent":["/**\n * @module @nhtio/adk/batteries/orchestration/approval\n *\n * Authority gating for a `reviewable` plan on the way to `executable`.\n *\n * The unit of authority is the {@link AuthorityClaim}: a narrow statement that one capability\n * may be exercised within one scope, with exactly one of the five verbs below. Claims are\n * independent — no verb implies any other, and a plan is never granted a hierarchy. This is\n * deliberate, and a reader new to this module should not assume otherwise.\n *\n * ## The five verbs, and why they do not nest\n *\n * - `list` — enumerate names. Kept apart from `read` because a filename is its own disclosure:\n *   learning that something exists is not the same as being able to inspect its contents.\n * - `read` — inspect the payload of a thing whose identity is already known.\n * - `create` — bring a thing into existence. Never shorthand for `update`: making a fresh empty\n *   object is materially safer than mutating existing state.\n * - `update` — modify an existing thing. Append-shaped operations classify HERE, not under\n *   `create`, which keeps `create` cheap to grant liberally (it can never silently mutate).\n * - `delete` — destroy a thing.\n *\n * There is no `update` implying `read`, and no `create` implying `update`. A reader will assume\n * a lattice that is deliberately absent; the absence is the feature, because each verb can then be\n * granted to exactly the callers that need it and nothing more.\n *\n * ## Approval is the transition\n *\n * \"Approved\" and \"executable\" are the same fact. There is no separate is-this-approved check to\n * keep in sync: a plan is executable iff a transition persists an {@link ApprovalRecord} bound to\n * its exact digest. This module therefore never answers \"has it been approved?\" as a question,\n * because the state machine cannot disagree with an answer that is never separately coded.\n *\n * Activation is all-or-nothing. {@link approvePlan} authorises the WHOLE reachable workflow in one\n * gate, because the owner is authorising a {@linkcode computeAuthoritySet workflow's} authority\n * set, computed over every reachable node. Approving a subset of steps would invite approving a\n * plan whose later steps can never run.\n *\n * ## The gate recomputes the set\n *\n * The store never re-derives an authority set. Recomputing it means walking the graph and knowing\n * what an {@link AuthorityClaim} is — battery policy a bring-your-own store has no business\n * reimplementing. The store guarantees only that whichever record it persists belongs to the\n * digest it commits.\n *\n * That leaves one residual misuse, named here: a caller may bypass {@link approvePlan} and call the\n * store's `transition` directly, persisting a record whose set does not match the plan. This is the\n * same class of error as calling `appendOps` directly instead of the authoring tools in the plan\n * module. It is not defended against, because the only defence would be duplicating this validator\n * into every store.\n *\n * A plan never holds a TurnGate. A live pending promise cannot be serialised by the encoder, and\n * `resolve`/`reject` no-op and return `void` once settled, so a gate produces no winner/loser\n * signal and would not survive a round-trip. A plan holds an {@link ApprovalRecord}, which is\n * plain data.\n */\n\nimport { foldOps } from './ops'\nimport { isObject } from '../../lib/utils/guards'\nimport { entryNodes, reachableFrom } from './plan'\nimport type { PlanStore, TransitionResult } from './store'\nimport type {\n  AuthorityClaim,\n  ApprovalRecord,\n  AuthorityVerb,\n  NodeId,\n  PlanNode,\n  RawPlanView,\n} from './types'\n\n/**\n * Computes the canonical authority set for a frozen plan.\n *\n * The set is the union of every reachable `call` node's claims, de-duplicated to exact triples and\n * sorted lexicographically by `capability`, then `scope`, then `verb`. Reachability is from the\n * entry node(s) via {@link reachableFrom}; a claim living on an unreachable node is excluded,\n * because the operator must see exactly what can actually run. Because the result is ordered and\n * de-duplicated, comparing two plans' sets is a plain set comparison with no expansion step.\n *\n * @remarks\n * The set is DERIVED as that union, so \"every reachable call's claims are in the result\" can never\n * fail and is not a meaningful check. The two non-vacuous gates are freeze-time reachability (a\n * separate work package) and {@link approvePlan approvePlan's} set-equality check.\n *\n * The optional `alreadyLive` predicate is the redundant-request short-circuit: a consumer whose\n * authority layer reports a claim already live can pass it so only the claims still needing a gate\n * are reported. It only shapes what THIS function returns for display/ask purposes; it does not\n * weaken {@link approvePlan approvePlan's} gate, which always compares the full reachable set.\n *\n * @param view - The frozen plan to summarise.\n * @param alreadyLive - Optional predicate; a claim it returns `true` for is omitted (already live).\n * @returns The sorted, de-duplicated, reachable-only authority claims.\n */\nexport function computeAuthoritySet(\n  view: RawPlanView,\n  alreadyLive?: (claim: AuthorityClaim) => boolean\n): AuthorityClaim[] {\n  const reached = new Set<NodeId>()\n  for (const entry of entryNodes(view)) {\n    for (const id of reachableFrom(view, entry.id)) {\n      reached.add(id)\n    }\n  }\n\n  const seen = new Set<string>()\n  const claims: AuthorityClaim[] = []\n  for (const node of view.nodes) {\n    if (!reached.has(node.id)) continue\n    const granted = callAuthority(node)\n    if (granted === undefined) continue\n    for (const claim of granted) {\n      if (_isLive(claim, alreadyLive)) continue\n      const key = `${claim.capability}\\u0000${claim.scope}\\u0000${claim.verb}`\n      if (seen.has(key)) continue\n      seen.add(key)\n      claims.push({ capability: claim.capability, scope: claim.scope, verb: claim.verb })\n    }\n  }\n\n  claims.sort(compareClaims)\n  return claims\n}\n\n/**\n * Approves a frozen `reviewable` plan and moves it to `executable`.\n *\n * The frozen content is rebuilt by folding the store's op log, the reachable authority set is\n * recomputed, and it is asserted SET-EQUAL to `record.authoritySet`. Only then is the store's\n * `transition` called, passing the recomputed digest as `expectedDigest` so the store refuses a\n * stale commit, and the decision as the approval payload.\n *\n * A set mismatch means the operator approved a different authority set than the plan actually\n * carries, so the request is refused BEFORE the store is touched. The refusal is a\n * `TransitionResult`-shaped failure returned rather than thrown: since `TransitionResult` has no\n * free-form reason, the failure is reported as a `digest_mismatch` (an authority-set inequality is\n * by definition a different content digest), carrying the actual digest and the assumed\n * `reviewable` state so a caller that lost can read what happened.\n *\n * @param store - The plan store holding the frozen plan.\n * @param planId - Identity of the plan to approve.\n * @param record - The operator's decision, whose `authoritySet` must match the plan's recomputed\n *   reachable set exactly (order-insensitive).\n * @returns The store's transition result, or a `digest_mismatch`-shaped refusal when the recomputed\n *   set does not match.\n */\nexport async function approvePlan(\n  store: PlanStore,\n  planId: string,\n  record: ApprovalRecord\n): Promise<TransitionResult> {\n  const ops = await store.readOps(planId)\n  const provenance = await store.readProvenance(planId)\n  const { view } = foldOps(planId, ops, provenance)\n\n  // The record must describe THIS plan at the digest the operator was actually shown. Checking\n  // only the authority set is not enough: two revisions of a plan can carry identical authority\n  // while differing in the staged ARGUMENTS an operator read — the path a file is written to, the\n  // text of a prompt, which branch a predicate takes. Approving the set without binding the digest\n  // authorises whatever the plan happens to say NOW.\n  //\n  // `expectedDigest` below cannot cover this. It is computed from the CURRENT fold, so it proves\n  // the plan did not move between this check and the commit — it says nothing about whether the\n  // operator ever saw that content.\n  if (record.planId !== planId || record.digest !== view.digest) {\n    return {\n      ok: false,\n      reason: 'digest_mismatch',\n      actual: { state: 'reviewable', digest: view.digest },\n    }\n  }\n\n  const actual = computeAuthoritySet(view)\n  if (!setEquals(actual, record.authoritySet)) {\n    return {\n      ok: false,\n      reason: 'digest_mismatch',\n      actual: { state: 'reviewable', digest: view.digest },\n    }\n  }\n\n  return store.transition(planId, {\n    from: 'reviewable',\n    to: 'executable',\n    expectedDigest: view.digest,\n    approval: record,\n  })\n}\n\n/**\n * Order-insensitive comparison of two canonical authority claim collections.\n *\n * Both inputs are expected to be canonical (de-duplicated), so equality is determined by matching\n * multiset membership; each key is consumed once to catch a duplicate on either side.\n */\nfunction setEquals(a: AuthorityClaim[], b: AuthorityClaim[]): boolean {\n  if (a.length !== b.length) return false\n  const remaining = new Map<string, void>()\n  for (const claim of a) remaining.set(claimKey(claim), undefined)\n  for (const claim of b) {\n    const key = claimKey(claim)\n    if (!remaining.has(key)) return false\n    remaining.delete(key)\n  }\n  return remaining.size === 0\n}\n\n/** Lexicographic ordering: capability, then scope, then verb. */\nfunction compareClaims(a: AuthorityClaim, b: AuthorityClaim): number {\n  if (a.capability !== b.capability) return a.capability < b.capability ? -1 : 1\n  if (a.scope !== b.scope) return a.scope < b.scope ? -1 : 1\n  return a.verb < b.verb ? -1 : a.verb > b.verb ? 1 : 0\n}\n\n/** NUL-joined key uniquely identifying one claim triple. */\nfunction claimKey(claim: AuthorityClaim): string {\n  return `${claim.capability}\\u0000${claim.scope}\\u0000${claim.verb}`\n}\n\n/** The authority claims carried by a node, if it is a `call` node; otherwise `undefined`. */\nfunction callAuthority(node: PlanNode): AuthorityClaim[] | undefined {\n  if (node.kind !== 'call') return undefined\n  // Defensive, BYO-store hardening: a folded definition that is not a well-shaped object yields\n  // no claim rather than a runtime crash. Claims themselves are validated field-by-field below.\n  if (!isObject(node.definition)) return undefined\n  const raw = (node.definition as Record<string, unknown>).authority\n  if (!Array.isArray(raw)) return undefined\n  return raw.filter(isClaimShape)\n}\n\n/** True when `value` is a well-shaped claim, so a malformed definition cannot crash the fold. */\nfunction isClaimShape(value: unknown): value is AuthorityClaim {\n  if (!isObject(value)) return false\n  return (\n    typeof value.capability === 'string' &&\n    typeof value.scope === 'string' &&\n    typeof value.verb === 'string' &&\n    validVerb(value.verb)\n  )\n}\n\n/** Type guard for the five discrete verbs. Verbs are independent; nothing nested hides here. */\nfunction validVerb(v: string): v is AuthorityVerb {\n  return v === 'list' || v === 'read' || v === 'create' || v === 'update' || v === 'delete'\n}\n\n/** The `alreadyLive` short-circuit: live claims are reported as already granted and skipped. */\nfunction _isLive(claim: AuthorityClaim, alreadyLive?: (c: AuthorityClaim) => boolean): boolean {\n  return alreadyLive !== undefined && alreadyLive(claim)\n}\n"],"mappings":"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"}