@sema-agent/core 7.26.0 → 7.26.2
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.md +73 -0
- package/dist/agents/verify.d.ts +16 -6
- package/dist/agents/verify.js +12 -5
- package/dist/core/checkpoint-store.d.ts +13 -10
- package/dist/core/gate-lanes.js +2 -1
- package/dist/core/gate-outcome.d.ts +54 -5
- package/dist/core/gate-outcome.js +17 -0
- package/dist/core/permission-rule-provider.js +6 -1
- package/dist/core/permission-rule-store.d.ts +36 -0
- package/dist/core/permission-rule-store.js +119 -19
- package/dist/core/permission-rule-sync.js +14 -5
- package/dist/core/runner/contracts.d.ts +48 -0
- package/dist/core/runner/decide-continuation.js +3 -1
- package/dist/core/runner/denial-limit-arms.d.ts +4 -3
- package/dist/core/runner/denial-limit-arms.js +2 -1
- package/dist/core/runner/prepare-inherited-gate.d.ts +1 -1
- package/dist/core/runner/prepare-inherited-gate.js +2 -1
- package/dist/core/runner/resume-apply.js +3 -1
- package/dist/core/runner/resume-policy-outcome.js +6 -6
- package/dist/core/runner/run-harness-handlers.js +2 -5
- package/dist/core/runner/run-identity-wiring.d.ts +6 -0
- package/dist/core/runner/run-identity-wiring.js +8 -3
- package/dist/core/runner/run-leg.js +9 -3
- package/dist/core/runner/runtask.js +2 -2
- package/dist/core/runner/session-rule-policy.d.ts +1 -1
- package/dist/core/runner/session-rule-policy.js +6 -7
- package/dist/core/session-policy-store.d.ts +18 -0
- package/dist/core/session-policy-store.js +3 -0
- package/dist/core/store-contracts/permission-rule-write-contract.js +16 -3
- package/dist/core/store-contracts/read-back-shape-contract.d.ts +56 -0
- package/dist/core/store-contracts/read-back-shape-contract.js +78 -0
- package/dist/core/tool-policy.d.ts +36 -12
- package/dist/core/tool-policy.js +22 -9
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/stores/file/adoption/adopt.js +4 -10
- package/dist/stores/file/permission-rule-store.d.ts +5 -1
- package/dist/stores/file/permission-rule-store.js +5 -3
- package/dist/stores/file/session-policy-store.d.ts +32 -0
- package/dist/stores/file/session-policy-store.js +72 -50
- package/dist/tools/fs/shell-read-check.d.ts +58 -1
- package/dist/tools/fs/shell-read-check.js +47 -33
- package/dist/tools/fs/shell-read-establish.js +19 -8
- package/dist/tools/fs/shell-read-judge.d.ts +3 -0
- package/dist/tools/fs/shell-read-judge.js +3 -1
- package/dist/tools/fs/shell-read-plan.js +16 -17
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +52 -2
- package/wire-changes/core@7.26.1.json +605 -0
- package/wire-changes/core@7.26.2.json +106 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.26.2 — 2026-09-21
|
|
4
|
+
|
|
5
|
+
### An assignment word is not a path; its VALUE is, and the question about a value is who may open it (#1004; behaviour NARROWING and RELAXING, both; +0 exports; @cli @test)
|
|
6
|
+
|
|
7
|
+
- **What changed.** `A=~/.env; true` was refused on every seat with `read_path_denied` and `target: "A=~/.env"` — the whole assignment WORD. Nothing in that command reads a file: bash sets a variable and runs `true`. The mint offered the word itself to the deny judge whenever the text could not settle the VALUE (a tilde bash expands at the start of a value and after each `:`, an append's earlier content), and a deny row whose leading double-star segment swallows the `A=~/` then read the rest as the guarded name. The refusal named a file that cannot exist, and the same row did not match `A=~/secret` unless spelled to — a barrier by coincidence of pattern shape.
|
|
8
|
+
- **One rule.** Every candidate such a word hands the judge is a fact about its value, and the question about the value is WHO MAY OPEN IT. At an operand slot that is the program's own grammar (`dd if=~/x` keeps its fail-closed answer, now accounted as `"~/x"` rather than `"if=~/x"`). For an environment word it is the program the segment STARTS: `A=~/.env cat safe.txt` hands `cat` a value bash expands and `cat` may open; `A=~/.env; true` starts no program; `A=~/'.env' true` starts one declared to open no path operand. Where a program may open it, an environment value that names ONE path is PLACED the way an operand is — a leading tilde against the declared home — so the deny judge and the refusal sentence name the real file.
|
|
9
|
+
- **NARROWING.** A value bash expands from a tilde is now judged against the declared home instead of being read as the literal word: `A=~/.env cat safe.txt`, `env A=~/.env cat safe.txt`, `A+=~/.env cat safe.txt`, `GIT_CONFIG_GLOBAL=~/.env git config --global --list` and `BASH_ENV=~/.env bash script.sh` refuse with `target: "<home>/.env"` on all three seats. The last two are the point: a program may open what its environment names (verified against the real `git`, which prints such a file's contents), and 7.26.1 refused those only when the deny row happened to swallow `NAME=~`. An absolute-path row never did.
|
|
10
|
+
- **RELAXING.** `A=~/.env; true` and `A=~/'.env' true` are vouched where they were refused: no program is started that could open the value, and the word itself is nobody's filename. The read boundary is not relaxed — those commands open nothing.
|
|
11
|
+
- **Who is hurt.** A deployment reading the refusal's `target` as the literal command word sees a path instead; that is the repair. A command whose environment value the text can settle is judged exactly as before. A value a `:` joins into two paths is placed by nobody (one spelling cannot carry two) and is no candidate — the remaining half of KL-993b.
|
|
12
|
+
- **Compensation.** The refusal's own judgement is pinned: a `read_path_denied` target is a path spelling, never a word carrying `=`, and the path quoted in the sentence is that target (`test/backlog1004-assignment-word-not-a-path.test.ts` ②). KL-993b records what is left.
|
|
13
|
+
### Every leg of the verify gate offers its wiring manifest, and says which leg it is (#1005; additive; +0 exports; @server)
|
|
14
|
+
|
|
15
|
+
- **What was unreachable.** `runWithVerification` / `resumeWithVerification` / `runDeveloperTask` / `verifyCompleted` return a `TaskResult` and drain each leg's event stream internally, so the `wiring_manifest` frame every prepared leg pushes died inside the gate. A deployment recording what each attempt was wired with — its tool roster, its MCP rows, its governance section — saw "done" and nothing else, while the gate itself knew exactly which leg it had just dispatched.
|
|
16
|
+
- **What changes.** `RunInternals` gains `onWiringManifest?: (manifest, leg?) => void`, the fourth observation sink on the trusted core-caller channel beside `onActivity` / `onForwardEvent` / `onWorkspaceResolved`, and never a `TaskSpec` field. It is handed the very object that leg's frame carries, once per prepared leg, at the moment the frame is pushed — after prepare, before the leg's first model or tool interaction — on fresh and resumed legs alike.
|
|
17
|
+
- **The leg identity is minted by the composition, never by the engine.** The verify entries dispatch each leg with a per-dispatch copy of the internals object whose sink closes over that leg's `{kind: "impl" | "verifier" | "fix", round}`: round 0 for the entry implementation leg, the round a verifier verifies, the round whose findings a fix answers. The engine stamps none and infers none, so a leg nobody labelled — a plain `runTask`, a workflow agent call, a cascade rung — reaches the sink with `leg` absent, which is the honest reading of an unlabelled leg. Nothing a caller can set ON the object produces an identity, and the identity itself goes out FROZEN and fresh per dispatch — an observer that writes to what it was handed is refused, rather than rewriting what the next leg, or the next run, reports. This is a different axis from the manifest's own `leg` field (`root` / `child` / `resume`): a resumed fix leg carries both, and both are true.
|
|
18
|
+
- **Containment, and one notifier per run.** The sink is contained by the same run-scoped safe notifier as `onActivity`: a throw — or an `async` sink returning a rejecting promise — is swallowed, counted and disclosed once, never faulting the leg whose manifest it was handed. That notifier is now minted in the identity-wiring lane (the run's first internals-sink call) and shared with the harness handlers, where each lane used to mint its own; bounded disclosure is per notifier, so one account per run is what makes "reported once" true across the sinks on one object.
|
|
19
|
+
- **Who is hurt.** A caller that wires nothing is dispatched exactly as before (the per-dispatch copy is made only when the sink is present). A caller that DOES wire it gets one documented delta on top of the #246 forwarding shape: the gate's legs receive a fresh shallow copy carrying the bound sink instead of the caller's object by identity — every other field still rides by the caller's own reference, the caller's object is never mutated, and the `isolation` door still re-reads that same object before every leg.
|
|
20
|
+
### A deployment's policy refusal stops speaking as the user (#1016; ADDITIVE closed-set word; +0 public exports; @server @cli @client-core)
|
|
21
|
+
|
|
22
|
+
- **What was wrong.** A shell running headless wires an `onAsk` approver that can show nobody a prompt, so it refuses on the deployment's behalf. The only way to spell that was `false` (plus a reason), and `false` means one thing to the engine: a person said no. The model was therefore told *"The user doesn't want to proceed with this tool use … treat it as the user's guidance"* and *"STOP what you are doing and wait for the user"* — about a machine's refusal, in a run where no user turn is ever coming. The deployment's own words about WHY rode a fence labelled as the user's.
|
|
23
|
+
- **The word.** `AskOutcome.settledBy` gains `"policy"` and the durable decide's `hostDecision.decidedBy` gains the same word — one fact, the same spelling on both legs. `SettlementKind` gains `policy_refused` (`who.party: "none"`, a refusal in `SETTLEMENT_IS_REFUSAL`), which is what `tool_end.gate.settlement.kind` and the durable row then carry.
|
|
24
|
+
- **The sentence.** One composer (`policyRefusalMessage`, module-internal — both legs call it, neither re-spells it) mints it for both legs: `approval required for "<tool>" but this run has no interactive approval channel: <ask prompt>` / `The deployment's policy declined this tool use; the call did NOT run. Action requires interactive approval and permission prompts are not available in this context.` plus a posture directive that tells the model to report what was blocked and get on with the work that needs no approval. A note rides a fence labelled `policy note` — relayed verbatim, never licensed as anyone's guidance. `humanRefusalMessage` is reached only by a person's refusal now.
|
|
25
|
+
- **The default does not move.** An omitted `settledBy` still reads as `"human"`, and `hostDecision.decidedBy` keeps both of its existing words: a deployment that never learns the new word is byte-unchanged on every face. Only a host that SAYS `"policy"` gets the policy sentence.
|
|
26
|
+
- **Who is hurt.** A consumer whose table over `SettlementKind` is exhaustive reds on the new member — that red is the notification, and it is what a closed set is for. A shell that adopts the word also changes its own turn shape: a BARE `human_refused` is the issuing batch's control-flow boundary (siblings settle un-executed, the turn ends awaiting the user) and `policy_refused` deliberately is not, because there is no user to await. The refusal still stands; the run continues.
|
|
27
|
+
- **Two screens widened, neither loosened.** `{allow: true}` beside any non-person word is a fail-closed `approver_contract` deny (was: the `timeout` word only), and a durable `decision:"allow"` is refused for every decider that is not `"person"` (was: `sla_timeout` only). One condition each, over the whole non-person family, so the next word is screened the day it is added.
|
|
28
|
+
- **The hole the word opened, found by the adversarial round.** "Was this deny made by nobody?" — the question the classifier's denial-limit fallback asks before stopping a run whose questions nothing can answer — was a hand-written two-word disjunction at the inherited chain's fold and a three-word one at the main gate. `policy_refused` answered it by falling out of both: a headless lane that hit the denial limit and refused BY POLICY no longer reached the stop that the same lane reached when it refused with a bare `false`. `SETTLEMENT_NOBODY_JUDGED` is now that question, once, with a compile-time fence over the whole set, and both sites read it. **Consequence for a shell adopting the word:** a run that hits the classifier denial limit on a policy-refusing SYNCHRONOUS lane ends `failed` with `classifier.denial_limit`, as a fully headless one already did. **Stated precisely, because the leg matters:** that stop seat exists on the synchronous gate and the inherited chain, and on NEITHER leg of a durable resume — a parked fallback ask answered through `/decide` re-parks instead of stopping, for every decider word alike (measured: `person` and `policy` produce identical runs). That gap predates this word and is not narrowed by it; it is recorded for its own fix.
|
|
29
|
+
- **Doc repairs found on the way.** `INTEGRATION-CORE` §1.3 still described a flat `tool_end.settledBy` and §4.4 a top-level `settledBy` on a decide; both were retired at 7.6.0 and a consumer following them was reading fields no release mints. §1.3 now documents the live `gate` record and §4.4 the `hostDecision` facts plus the refused keys. Same repair in the governed-agent example's `seats-2` row.
|
|
30
|
+
- Wire table: `docs/wire-consumption/unreleased-1016.md`.
|
|
31
|
+
|
|
32
|
+
## 7.26.1 — 2026-09-21
|
|
33
|
+
|
|
34
|
+
### One store, one answer about one file; one lineage judgment at every station (#1003 + #1000; narrowing; +0 exports; @server)
|
|
35
|
+
|
|
36
|
+
- **#1003 — the enumeration face judged less than the per-key read.** `FileSessionPolicyStore.getRules` validated a row's SHAPE (a `rev` of at least 1, a whole `gen`, string-array rule fields) and answered ABSENT with a disclosure when it failed; `listBySession` checked "a JSON object carrying our `__sid`" and cast. So one store answered two different things about one file: a row carrying `toolDeny: "rm"` or `rev: 0` was absent to the keyed read and served VERBATIM through the enumeration face that the cross-backend policy export and a deployment's session listing read.
|
|
37
|
+
- **What changes.** The shape judgment is one function now, and both rules-serving faces run it, in the same order, with the same disclosure words. **Narrowing:** a malformed row is absent from `listBySession` and disclosed once through `onCorruptRead`, where it used to be returned. The tolerance is unchanged — one bad row does not void an enumeration. The enumeration attributes before it judges shape, so another session's malformed row is that session's fact rather than noise repeated in every other session's listing.
|
|
38
|
+
- **Who is hurt.** A deployment that read `listBySession` expecting whatever is on disk (a characterization test, a backup differ) sees the row vanish and one extra disclosure. That is the point: nothing could ENFORCE that row, so nothing should have exported it either. The erase sweep and the ownership rebind are unaffected — they read a row's attribution, never its rules, so a malformed-rules row is still erasable and still travels with its key.
|
|
39
|
+
- **#1000 — an absent `gen` meant three things.** `StoredSessionRules.gen` names the lineage a row's `rev` counts within, and the type says an absent one is generation 0. Three stations read it otherwise: the file store's stamp took a READ row carrying no `gen` for "no lineage" and opened a new generation over it; the live reader skipped the comparison unless BOTH rows carried the field; the inherited-gate merge read either side's absence as "same lineage, go ahead". A row of generation 1 could therefore pass for a continuation of a row that spelled its generation nowhere.
|
|
40
|
+
- **What changes.** `continuesLineage(a, b)` is that judgment, in one place, absent = 0; all three stations read it, including the per-call handoff claim, which compared revs without asking at all. **Narrowing:** a live row of another generation no longer replaces the one a leg enforces or the one a checkpoint recorded. The file store's stamp continues a row it could READ and keeps the new generation for the case it was written for — bytes that exist and cannot be read as a row. That half is load-bearing for the other: without it, an upgraded deployment's first tightening over a pre-lineage row would open a generation the leg enforcing that very row would then refuse to follow.
|
|
41
|
+
- **Who is hurt (@server).** A durable twin that does not persist `gen` while core's checkpoint recorded one has its live ancestor rows refused at the merge — fail-closed, the checkpoint's row stands. The repair is to carry the column; both bundled backends do. Absence still carries one other fact, and it is a different question: a backend that stamps no lineage at ALL offers its reader no recycle evidence, so the live reader keeps the older, weaker guard there (it watched the row vanish). What that guard cannot do at the MERGE, which has no observation channel, is recorded as KL-1003a.
|
|
42
|
+
- **Three more from the adversarial round, same class.** (1) **An erase now ends the lineage of EVERY row it removes**, including a torn row belonging to another session. The sweep deletes every unparseable row it meets — that ruling stands — but it used to end the lineage only when the mark named the session being erased, so a foreign key lost its row and kept its generation: the key's next write found no file, continued a generation a leg still enforced, and was taken for that row at a higher rev (measured: the leg answered `allow`, now `deny`). One removal, one lineage transition; the "whose session is this" special case is gone. (2) **The no-lineage fallback reads the LIVE row.** Whether a backend stamps lineages at all is a fact about what it writes NOW; reading the ENFORCED row's absence the same way refused a legitimate generation-0 tightening forever after one transient gap — precisely the row the new file-store stamp produces on an upgraded deployment. A genuinely lineage-less recreation after an observed gap is still refused (pinned as the control). (3) **`listBySession` no longer casts a principal it cannot spell**: a `__principal` that is neither a string nor null is disclosed and skipped instead of shipped through a `string | undefined` seat. That is the enumeration's own attribution question — the keyed read never asks it, because its key comes from the caller.
|
|
43
|
+
- Wire table: `docs/wire-consumption/unreleased-1003.md`. Export surface: `npm run handoff:diff v7.26.0 HEAD` = +0 / −0.
|
|
44
|
+
### A rule-write outcome is READ, and every revision it carries is judged (#1007; behaviour NARROWING; +0 exports; @server @cli @client-core)
|
|
45
|
+
- **What changed.** `apply`'s answer is DATA — a foreign backend, a deserialized reply, a twin compiled against another generation — and both write entries used to discriminate it by asking whether it carried `conflict` or `refused`, calling everything else a landing. `{}` satisfies that test, so a write nobody could confirm was reported as committed at revision `undefined` through a field the type says is a `number`; `null` threw the `in` operator out of an entry whose declared result union has no throwing arm. One reader (`readRuleWriteOutcome`, module-internal) now tells the arms apart once, for all three call sites (`addPersistedRule`, `removePersistedRule`, the sync round).
|
|
46
|
+
- **What a revision is.** A non-negative safe integer. **Zero is a landing**: a session-scope add lands in a partition that has no revision of its own and the landing reports the durable revision unchanged, which is `0` for a bucket nothing has written yet. A string, a negative, a fraction, `NaN`, `null` or an absent `rev` is an answer nothing can read.
|
|
47
|
+
- **Who is hurt.** A host whose rule backend answered a landing without a readable revision was being told `added` / `removed`; it is now told `failed` with `committed: "unknown"` and reconciles with `list()`. That is the narrowing: a false success becomes an honest indeterminate. **Compensation**: the add entry's indeterminate arm takes an evidence read-back, so a backend that really landed the write still reports `added` — only a backend that landed nothing, or cannot be read, reaches the failure. **Consumer matrix**: in-tree backends (file, in-memory, the session partition) are unaffected — each already answered a readable revision, pinned by the positive controls.
|
|
48
|
+
- **Tags are judged by VALUE, not presence.** `{refused: null}` is not a refusal and `{conflict: false}` is not a decline; an answer carrying both tags is an answer about neither. Reading a tag by presence turned a malformed answer into a DEFINITE `committed: "no"`, which is the same over-claim one arm over.
|
|
49
|
+
- **The landing report rides the same judgement.** A `sync` report that is not an object, or whose `droppedInbound` / `quarantined` are not lists of drop records, no longer reaches the sync round's walk (which threw a type error at a caller holding a committed write). The revision still proves the landing, so the landing stands and the lost report is DISCLOSED in the round's warnings.
|
|
50
|
+
- **The session partition's answer is judged before translation.** `SessionRuleApplyResult` has two named arms (`{landed: true}` / `{refused: "session_ended"}`); the routing writer read it by absence of a refusal, so a host partition answering `{}` produced a well-formed landing at the caller's own revision. Exactly one arm now; anything else is a write face that failed, which the entries report as indeterminate.
|
|
51
|
+
- **A readable revision is not evidence of a row.** The judgement above is about the ANSWER; a backend that reports a landing at a perfectly readable revision and writes nothing answers just as well as one that wrote. Both add doors (the record-less entry and the consent protocol's redemption) now WITNESS a reported landing before reporting it — the same read-back the indeterminate arm already took, asking the evidence face for THIS call's own dot, which is minted once and so identifies this call's work and nobody else's. Not seen ⇒ `committed: "unknown"`, never `"no"` (an eventually-consistent backend may converge later); the read-back itself failing ⇒ `unknown` as well. A backend that really wrote still lands, including when it under-reports its revision.
|
|
52
|
+
- **Two smaller over-claims, same ticket.** The bundled file backend's read-back failure said "the change was not committed" about bytes it had not observed — it now says only that the store did not read back as the version just written, and that whether the change reached disk is unobservable from there. And a write-face accessor that THREW was reported as "nothing was written": the backend's own code had run on the seat whose purpose is to touch bytes, so that arm is now `unknown` (an ABSENT face keeps `no` — nothing ran).
|
|
53
|
+
- **Backend authors**: `permissionRuleWriteContract` now asserts that every arm carrying a revision carries a readable one, and that a decline is tagged `conflict: true`. A backend answering `{}`, or tagging a decline with anything else, fails the kit — it was being certified as usable by an engine that reads its answers as unreadable.
|
|
54
|
+
|
|
55
|
+
### `store.read_back_shape_judged` — one read-back rule across every store family (#1009; additive; +6 exports; @server @test)
|
|
56
|
+
- **The sentence.** `@contract store.read_back_shape_judged` (`src/core/store-contracts/read-back-shape-contract.ts`, quoted for integrators in `docs/INTEGRATION-CORE.md` §5.0): every read a store answers — a single get and an enumeration alike — judges the SHAPE of what it read; a body that is not the shape the store writes reaches the caller as a refusal or as an absence carrying a disclosure that names the loss, never as an empty set, an empty object or a default. Which read-back failures a store TOLERATES is registered per store in §5.0, never inherited from another store's.
|
|
57
|
+
- **The suite.** `storeReadBackShapeContract` (exported, vitest-free like its siblings) plants four corrupt-body forms — `not-an-object` / `missing-required` / `wrong-type` / `torn-json` (`CORRUPT_BODY_FORMS`) — and asks one mechanical question per form: does the corrupt read answer something OTHER than the empty read, and does it keep the planted bytes out of its answer. Clause 1 is the control: the empty and seeded reads must SUCCEED, answer differently, and the empty answer must be REPRODUCIBLE — which is what stops a face that throws at everything, a face that answers everything alike, and a face carrying a per-read id or timestamp from passing for free.
|
|
58
|
+
- **Its ceiling is written down**: clause 2 proves the corrupt case is distinguishable, not that the distinguishing answer is correct. A backend with no bytes a foreign writer could damage declares that ONCE, at the subject, with a reason — never per attempt, where a planter that had simply gone blind could exempt a backend that still had bodies.
|
|
59
|
+
- **The census** (`test/backlog1009-store-read-back-shape.test.ts`) carries a row for every kit in `src/core/store-contracts/`: bound, pending a named ticket, unbound with what is known, or not a store. A kit with no row reds — "nobody wrote one" and "there is nothing to write" may not look the same. Bound today: the durable permission-rule bucket's `list` / `readRaw` / `quarantined`, the unified `effective`, and `SessionPolicyStore.getRules`.
|
|
60
|
+
- **One store took the fix the sentence asks for**: the durable rule bucket's `quarantined()` answered "the quarantine is empty" about a bucket it could not read — exactly the rows a person is being sent to look at. It now refuses the way `list()` does; an ABSENT bucket is still an empty quarantine.
|
|
61
|
+
- **@server**: this is the sentence S-550 asked for. The SQL twin mounts the same suite against its own read faces; `docs/INTEGRATION-CORE.md` §5.0 is the clause-by-clause table.
|
|
62
|
+
### The shell read station has a consumer face on the package root (#1010; additive; +20 exports; @cli)
|
|
63
|
+
- **Before.** Everything the read boundary knows lived inside `src/tools/fs/`, so a downstream fence over the 24-grid decision table had to deep-import `dist/tools/fs/*.js` — a path the package's `exports` map does not promise and a directory re-arrangement breaks. A prior statement that this face was already on the root was wrong: the package root had zero of these names.
|
|
64
|
+
- **After.** Twenty names on the root, and nothing else: the vocabulary (`SHELL_READ_FINDINGS`, `SHELL_READ_SEATS`, `SHELL_READ_FINDING_RANK`, `SHELL_READ_DECISION` — the closed sets already registered in `docs/CLOSED-SETS.md`), thirteen shape types (`ShellReadVerdict`, `ShellReadBoundary`, `ShellReadPlan`, `EstablishedRead`, `ReadEstablishment`, …), and ONE call: `judgeShellRead({ command, env, boundary, seat })` → `{ plan, establishment, verdict, offer? }`.
|
|
65
|
+
- **The one call is not a second judgment.** The station's pipeline is now written once and has two entries: the gate input's judge leg, and this one. They differ only in bookkeeping — the gate remembers an admission, the consumer face brings its own establisher — so the verdict a consumer reads is the verdict the gate applies, `offer` included (the verdict's grant candidate through the gate's own `normalizeReadRootCandidate`). Pinned against the real lane (`runToolGate` + the station over a `NodeExecutionEnv` and a real directory) on ten command shapes × three seats.
|
|
66
|
+
- **The steps between stay private** (`planShellReads`, the establisher, `judgeEstablishedPlan`, `foldShellReadFindings`, `decideShellRead`, `readRootGrantCandidateOf`, `judgeShellReadLexically`): they are the station's working, not its answer, and they are free to move. A fence that needs one should name the grid and why the one call is not enough.
|
|
67
|
+
- **Which `ExecutionEnv` an offline judgment may use** — `docs/INTEGRATION-CORE.md` §9. `NodeExecutionEnv` over a temporary directory is the supported face: the same adapter production runs on. `StubExecutionEnv` reads LEXICALLY (measured: a symlink inside the roots pointing out reads `none`/`vouch` under it where the real env reads `outside_roots`/`mandated`; a pattern it cannot expand reads `unestablishable` where the real env names the targets), and `judgeShellReadLexically` is a third reading again — on a pattern that expands onto such a symlink it vouches. Core ships no in-memory filesystem fixture: a third derivation of `realpath`/glob semantics is a third place for them to be wrong.
|
|
68
|
+
- **What the one call does NOT carry: a deadline.** Bounding an environment-facing wait and substituting a fail-closed verdict is the gate FOLD's policy (`withGateTimeout` + the station's `unanswered` arm), not the station's — the station has never held one on either entry. So a stalling filesystem adapter, or a `boundary.alsoInside` promise that never settles, stalls this call where a run would refuse; `signal` reaches the environment's own `canonicalPath` / `listDir` and is advisory, not a deadline (measured: an already-aborted signal still answers). A consumer wanting the gate's posture races its own deadline and reads the row the fold reads, `SHELL_READ_DECISION.unestablishable[seat]`. Recorded as `KL-1010a`; a second deadline inside the library would be a second place deciding it, and would turn a slow-but-correct filesystem's true answer into a fail-closed one with nobody having asked.
|
|
69
|
+
- **Who is hurt / compensation / consumers.** Nobody: 0 exports removed, 0 changed kind, no gate decision moves, no wire key changes. Deep `dist/tools/fs/*.js` imports keep working. @cli L-467 moves its fence to the root face at its own pace. Wire table: `docs/wire-consumption/unreleased-1010.md`. Export surface: `npm run handoff:diff v7.26.0 HEAD` = +20 / −0.
|
|
70
|
+
|
|
71
|
+
### Merge-tree adversarial round: two fixes (R1 #1000 × erase; R2 #1014 the traversal grant)
|
|
72
|
+
- **R1.** The erase sweep ends a torn row's lineage for EVERY torn row it removes, and the row always goes (a crash artifact must not outlive a delete that reported success): a mark that names no session is bumped as found; a torn row with NO mark leaves a lost-lineage tombstone (`{__hwm: 0, __gen: 0, __lineage: "lost"}`) in the mark's place — without evidence a minted generation could repeat one a leg still enforces (round 2: an erased-once key's enforced row is already generation 1) — and a torn row whose mark is corrupt leaves that mark as it is. A write to a key carrying the tombstone, or an unreadable row and no mark, is refused (`SessionPolicyError("corrupt")`) rather than guessed; deleting the mark is the operator's restart, as the mark's own contract says. Before the fix a session-less mark was skipped (the recreation continued the enforced generation) and a missing one was minted.
|
|
73
|
+
- **R2 (#1014).** A traversal's root-grant offer names what the environment said the target IS, not what its provenance was: `EstablishedRead.node` (`"directory" | "file"`, set only by the walk — the root by its own listing, a member by whether the walk listed it) and `readRootGrantCandidateOf` grants a file through its containing directory. Before the fix `grep -R needle /abs/file.txt` and a recursive read descending a link onto an outside file offered the FILE as a directory grant (`{ dir: …/report.txt }`); a link onto a directory is still granted as that directory (control). The same derivation serves the gate and `judgeShellRead`, so both faces change together.
|
|
74
|
+
|
|
75
|
+
|
|
3
76
|
## 7.26.0 — 2026-09-21
|
|
4
77
|
|
|
5
78
|
### Six root exports the neighbours were hand-copying (#995; additive; +6 exports; @server @cli @client-core)
|
package/dist/agents/verify.d.ts
CHANGED
|
@@ -253,8 +253,8 @@ export declare function verifyCompleted(runner: RunnerSelfSeat, result: TaskResu
|
|
|
253
253
|
* WHY THE SEAT EXISTS: these entries return a `TaskResult` and DRAIN each leg's event stream
|
|
254
254
|
* internally, so the host never holds a `TaskStream` for any of them. Without this parameter a
|
|
255
255
|
* deployment can observe nothing a leg did — tool beats, forwarded child progress, the resolved
|
|
256
|
-
* workspace — only the final result. Wiring an observation sink here
|
|
257
|
-
* `onForwardEvent`, `onWorkspaceResolved`) is the only way in.
|
|
256
|
+
* workspace, the leg's own wiring manifest — only the final result. Wiring an observation sink here
|
|
257
|
+
* (`onActivity`, `onForwardEvent`, `onWorkspaceResolved`, `onWiringManifest`) is the only way in.
|
|
258
258
|
*
|
|
259
259
|
* ATTRIBUTION — WEAK, and deliberately stated as such: the legs run SEQUENTIALLY (impl → verifier →
|
|
260
260
|
* fix → verifier → …) and each is dispatched with the SAME object, so a leg's own tool beats are all
|
|
@@ -264,10 +264,20 @@ export declare function verifyCompleted(runner: RunnerSelfSeat, result: TaskResu
|
|
|
264
264
|
* no verifier marker either (an impl spec carrying its own `outputSchema` mounts it too).
|
|
265
265
|
* {@link VerifyConfig.onRound} bounds a ROUND, not a leg — the segment before it holds the impl (or
|
|
266
266
|
* fix) leg's beats AND that round's verifier beats together. A host that needs true per-leg
|
|
267
|
-
* attribution must drive the legs itself (one `runner.runTask` per leg) rather than
|
|
268
|
-
* this stream. Two further honesty notes: SUBAGENT events forwarded through
|
|
269
|
-
* carry their own `sourceTaskId`, and a BACKGROUND child outlives the leg that
|
|
270
|
-
* frames can arrive after that leg returned, i.e. interleaved into a later leg's
|
|
267
|
+
* attribution for THOSE frames must drive the legs itself (one `runner.runTask` per leg) rather than
|
|
268
|
+
* read it out of this stream. Two further honesty notes: SUBAGENT events forwarded through
|
|
269
|
+
* `onForwardEvent` do carry their own `sourceTaskId`, and a BACKGROUND child outlives the leg that
|
|
270
|
+
* spawned it — its frames can arrive after that leg returned, i.e. interleaved into a later leg's
|
|
271
|
+
* segment.
|
|
272
|
+
*
|
|
273
|
+
* ONE SEAT IS EXEMPT, because its frame is per-leg by construction: `onWiringManifest` fires exactly
|
|
274
|
+
* once per leg, at that leg's start, and this gate hands it the leg's own IDENTITY beside the
|
|
275
|
+
* manifest (the axis, and the two readings a consumer cannot infer, are documented at the seat).
|
|
276
|
+
* That identity is minted HERE, in the dispatch that knows it — the engine stamps none and infers
|
|
277
|
+
* none — so a consumer of this seat reads per-leg attribution instead of reconstructing it from
|
|
278
|
+
* arrival order. This is also the one place the forwarding below is not by identity: a caller that
|
|
279
|
+
* wired that sink gets a fresh per-dispatch copy carrying the bound sink (see `withLegIdentity`),
|
|
280
|
+
* every other field still by its own reference.
|
|
271
281
|
*
|
|
272
282
|
* FAILURE-ISOLATED since #248, by the same primitive as {@link VerifyConfig.onRound} /
|
|
273
283
|
* {@link VerifyConfig.onVerifierPause}: the fields on this object are invoked by the ENGINE on the
|
package/dist/agents/verify.js
CHANGED
|
@@ -81,6 +81,13 @@ function refuseUnhonorableInternals(internals, phase) {
|
|
|
81
81
|
`implementation leg keeps whatever it did (a consumed checkpoint stays consumed, its session stays the caller's to reclaim).`));
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
function withLegIdentity(internals, leg) {
|
|
85
|
+
if (internals?.onWiringManifest === undefined)
|
|
86
|
+
return internals;
|
|
87
|
+
const identity = Object.freeze({ ...leg });
|
|
88
|
+
return { ...internals, onWiringManifest: (manifest) => internals.onWiringManifest?.(manifest, identity) };
|
|
89
|
+
}
|
|
90
|
+
const IMPL_LEG = { kind: "impl", round: 0 };
|
|
84
91
|
function fixObjective(findings) {
|
|
85
92
|
const list = findings.length
|
|
86
93
|
? findings.map((f) => `- ${sanitizeUntrustedText(f)}`).join("\n")
|
|
@@ -173,7 +180,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
|
|
|
173
180
|
...(specBase.promptProfile !== undefined ? { promptProfile: specBase.promptProfile } : {}),
|
|
174
181
|
signal: specBase.signal,
|
|
175
182
|
};
|
|
176
|
-
const v = await runner.runTask(verifierSpec, legInternals());
|
|
183
|
+
const v = await runner.runTask(verifierSpec, withLegIdentity(legInternals(), { kind: "verifier", round }));
|
|
177
184
|
try {
|
|
178
185
|
const cost = (v.stats.costMicroUsd ?? 0) + (v.stats.nested?.costMicroUsd ?? 0);
|
|
179
186
|
return { verdict: v.structuredOutput, cost, ...(isDurablePause(v.terminal) ? { paused: { ...v, terminal: v.terminal } } : {}) };
|
|
@@ -259,7 +266,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
|
|
|
259
266
|
readDenyPatterns: foldedReadDeny,
|
|
260
267
|
}),
|
|
261
268
|
};
|
|
262
|
-
current = await runner.runTask(fixLegSpec, legInternals());
|
|
269
|
+
current = await runner.runTask(fixLegSpec, withLegIdentity(legInternals(), { kind: "fix", round }));
|
|
263
270
|
spend += (current.stats.costMicroUsd ?? 0) + (current.stats.nested?.costMicroUsd ?? 0);
|
|
264
271
|
implAccount.add(current.stats);
|
|
265
272
|
if (isDurablePause(current.terminal)) {
|
|
@@ -272,7 +279,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
|
|
|
272
279
|
}
|
|
273
280
|
export async function runWithVerification(runner, implSpec, config = {}, internals) {
|
|
274
281
|
refuseUnhonorableInternals(internals, "door");
|
|
275
|
-
const result = await runner.runTask(implSpec, internals);
|
|
282
|
+
const result = await runner.runTask(implSpec, withLegIdentity(internals, IMPL_LEG));
|
|
276
283
|
if (isDurablePause(result.terminal)) {
|
|
277
284
|
return { ...mapNestedSuspend(result), verification: { verdict: "unverified", unverifiedReason: pauseFamily(result.terminal), rounds: 0, findings: [] } };
|
|
278
285
|
}
|
|
@@ -281,7 +288,7 @@ export async function runWithVerification(runner, implSpec, config = {}, interna
|
|
|
281
288
|
}
|
|
282
289
|
export async function resumeWithVerification(runner, token, outcome, taskConfig, objective, config = {}, internals) {
|
|
283
290
|
refuseUnhonorableInternals(internals, "door");
|
|
284
|
-
const result = await runner.resume(token, outcome, taskConfig, internals);
|
|
291
|
+
const result = await runner.resume(token, outcome, taskConfig, withLegIdentity(internals, IMPL_LEG));
|
|
285
292
|
if (isDurablePause(result.terminal)) {
|
|
286
293
|
return { ...mapNestedSuspend(result), verification: { verdict: "unverified", unverifiedReason: pauseFamily(result.terminal), rounds: 0, findings: [] } };
|
|
287
294
|
}
|
|
@@ -291,7 +298,7 @@ export async function resumeWithVerification(runner, token, outcome, taskConfig,
|
|
|
291
298
|
export async function runDeveloperTask(runner, spec, config = {}, internals) {
|
|
292
299
|
const implSpec = { systemPrompt: CODE_AGENT_PROMPT, ...spec };
|
|
293
300
|
if (config.verify === false) {
|
|
294
|
-
const result = await runner.runTask(implSpec, internals);
|
|
301
|
+
const result = await runner.runTask(implSpec, withLegIdentity(internals, IMPL_LEG));
|
|
295
302
|
return { ...result, verification: { verdict: "unverified", unverifiedReason: "opted_out", rounds: 0, findings: [] } };
|
|
296
303
|
}
|
|
297
304
|
return runWithVerification(runner, implSpec, config, internals);
|
|
@@ -613,14 +613,17 @@ export type ResumeOutcome = {
|
|
|
613
613
|
answer?: QuestionAnswer;
|
|
614
614
|
/**
|
|
615
615
|
* The host's DECISION FACTS — who ended this wait: a person (`"person"`, the decide an operator made
|
|
616
|
-
* through the deployment's approval channel, with the attribution that channel reports),
|
|
616
|
+
* through the deployment's approval channel, with the attribution that channel reports), the
|
|
617
617
|
* host's own SLA sweep at the row's deadline (`"sla_timeout"` — FACET A of the deadline contract; only
|
|
618
|
-
* a sweep may say it,
|
|
619
|
-
*
|
|
618
|
+
* a sweep may say it), or the deployment's OWN POLICY (`"policy"` — a machine refused: no interactive
|
|
619
|
+
* approval surface exists for this row, or a standing posture declined it; the durable spelling of the
|
|
620
|
+
* synchronous leg's `AskOutcome.settledBy: "policy"`, so ONE refusal reads the same on both faces).
|
|
621
|
+
* Only a person can `allow` — the other two words name a wait that ended with nobody judging.
|
|
622
|
+
* REQUIRED: a decide that names nobody is not a record of anything that happened, and core does not guess.
|
|
620
623
|
*
|
|
621
624
|
* The host supplies FACTS, never the settlement WORD: core mints the record
|
|
622
|
-
* ({@link import("./gate-outcome.js").GateOutcome} — `human_allowed` / `human_refused` / `park_sla_expired
|
|
623
|
-
* joined with the row's persisted `origin`) at the resume entry, writes it onto the resolved row
|
|
625
|
+
* ({@link import("./gate-outcome.js").GateOutcome} — `human_allowed` / `human_refused` / `park_sla_expired`
|
|
626
|
+
* / `policy_refused`, joined with the row's persisted `origin`) at the resume entry, writes it onto the resolved row
|
|
624
627
|
* (`resolvedOutcome.gateOutcome`) and projects it onto the resolved call's `tool_end` frame. A caller
|
|
625
628
|
* that supplies `gateOutcome` itself is refused pre-CAS.
|
|
626
629
|
*
|
|
@@ -635,7 +638,7 @@ export type ResumeOutcome = {
|
|
|
635
638
|
* authorization; a replay states its own facts.
|
|
636
639
|
*/
|
|
637
640
|
hostDecision: {
|
|
638
|
-
decidedBy: "person" | "sla_timeout";
|
|
641
|
+
decidedBy: "person" | "sla_timeout" | "policy";
|
|
639
642
|
approver?: string;
|
|
640
643
|
};
|
|
641
644
|
/**
|
|
@@ -2175,8 +2178,8 @@ export declare class CheckpointError extends Error {
|
|
|
2175
2178
|
* re-fetch the pending list), `"boundInputHash"` (the input you reviewed has changed under the same
|
|
2176
2179
|
* action: re-review), or `"answer"` (the content-ask answer is missing, or was attached to a
|
|
2177
2180
|
* decision that cannot carry one: re-decide with a well-formed answer — ruled 2026-08-04), or
|
|
2178
|
-
* `"hostDecision"` (the decision facts are missing, name a decider outside `person`/`sla_timeout`,
|
|
2179
|
-
*
|
|
2181
|
+
* `"hostDecision"` (the decision facts are missing, name a decider outside `person`/`sla_timeout`/`policy`,
|
|
2182
|
+
* pair a non-person decider with an allow, or try to supply the minted word), or — design/252 G-7 — `"approver"` (the attribution is not a
|
|
2180
2183
|
* well-formed identifier: re-decide with a plain, bounded, control-character-free string, or omit
|
|
2181
2184
|
* it). The throw
|
|
2182
2185
|
* sites always knew which arm fired; this carries that fact instead of dropping it (ruled
|
|
@@ -2354,8 +2357,8 @@ export declare class CheckpointError extends Error {
|
|
|
2354
2357
|
* re-fetch the pending list), `"boundInputHash"` (the input you reviewed has changed under the same
|
|
2355
2358
|
* action: re-review), or `"answer"` (the content-ask answer is missing, or was attached to a
|
|
2356
2359
|
* decision that cannot carry one: re-decide with a well-formed answer — ruled 2026-08-04), or
|
|
2357
|
-
* `"hostDecision"` (the decision facts are missing, name a decider outside `person`/`sla_timeout`,
|
|
2358
|
-
*
|
|
2360
|
+
* `"hostDecision"` (the decision facts are missing, name a decider outside `person`/`sla_timeout`/`policy`,
|
|
2361
|
+
* pair a non-person decider with an allow, or try to supply the minted word), or — design/252 G-7 — `"approver"` (the attribution is not a
|
|
2359
2362
|
* well-formed identifier: re-decide with a plain, bounded, control-character-free string, or omit
|
|
2360
2363
|
* it). The throw
|
|
2361
2364
|
* sites always knew which arm fired; this carries that fact instead of dropping it (ruled
|
package/dist/core/gate-lanes.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { attestClassifierDenyCause, classifierDenyCauseOf, decisionText, describeThrown } from "./tool-policy.js";
|
|
2
2
|
import { classifierMayAnswer, classifierMayAnswerMandate, mandateDisclosureOf, persistedRuleMandateOf, ruleClearanceOf, safetyAxisOf, sandboxMayAdmitMandate, stampAskOrigin } from "./ask-origin.js";
|
|
3
3
|
import { classifierParseFailureDenyMessage, classifierUnavailableDenyMessage, denialLimitFallbackMessage, unarmedWindow } from "./auto-mode.js";
|
|
4
|
+
import { SETTLEMENT_NOBODY_JUDGED } from "./gate-outcome.js";
|
|
4
5
|
import { inlineUntrusted } from "./untrusted-text.js";
|
|
5
6
|
import { isRuleBehavior } from "./permission-rule-model.js";
|
|
6
7
|
import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
|
|
@@ -539,7 +540,7 @@ export async function runGateLanes(pass) {
|
|
|
539
540
|
input.autoMode?.denialTracking?.recordAllow();
|
|
540
541
|
}
|
|
541
542
|
else if ((resolved.action === "deny" &&
|
|
542
|
-
(resolved.settlement.kind
|
|
543
|
+
(SETTLEMENT_NOBODY_JUDGED[resolved.settlement.kind] || resolved.settlement.kind === "approver_unavailable")) ||
|
|
543
544
|
resolved.approverUnavailable === true) {
|
|
544
545
|
try {
|
|
545
546
|
input.onHeadlessDenialLimit?.({ toolName, toolCallId, fallback: unarmedWindow(askBeforeResolve.denialLimitFallback) });
|
|
@@ -29,9 +29,17 @@ import { type AutoModeClassifierRound } from "./auto-mode.js";
|
|
|
29
29
|
* unreadable member, a timeout-settled allow, a refused attribution…).
|
|
30
30
|
* - `presentation_failed` — the args or the edit could not be safely presented/adopted (unclonable).
|
|
31
31
|
* - `blanket_allow_refused` — a blanket `onAsk:"allow"` met a `requiresRealApproval` ask.
|
|
32
|
+
* - `policy_refused` — the DEPLOYMENT'S OWN POLICY refused: the approval channel reported that a machine,
|
|
33
|
+
* not a person, ended this wait (a headless lane with no interactive approval surface, a standing
|
|
34
|
+
* deployment posture). Distinct from `human_refused` because the party differs and the model is told a
|
|
35
|
+
* different thing: a person's "no" is the user's direction, a policy's is a capability of this run.
|
|
36
|
+
* Distinct from `no_approver` because an approval channel WAS wired and answered — core did not fall
|
|
37
|
+
* back to its own headless posture. The refusal's own note, when the channel attached one, reaches the
|
|
38
|
+
* model as the policy's words and NOT as a person's; it is not on this record (the note seat belongs to
|
|
39
|
+
* the two human words, whose ABSENCE is a control-flow fact this family does not have).
|
|
32
40
|
* - `task_aborted` — the wait's abort signal ended it (the run's own end, or a turn-level interrupt).
|
|
33
41
|
*/
|
|
34
|
-
export declare const SETTLEMENT_KINDS: readonly ["human_allowed", "human_refused", "approval_window_expired", "denial_limit_window_expired", "park_sla_expired", "no_approver", "approver_unavailable", "approver_error", "approver_contract", "presentation_failed", "blanket_allow_refused", "task_aborted"];
|
|
42
|
+
export declare const SETTLEMENT_KINDS: readonly ["human_allowed", "human_refused", "approval_window_expired", "denial_limit_window_expired", "park_sla_expired", "no_approver", "approver_unavailable", "approver_error", "approver_contract", "presentation_failed", "blanket_allow_refused", "policy_refused", "task_aborted"];
|
|
35
43
|
export type SettlementKind = (typeof SETTLEMENT_KINDS)[number];
|
|
36
44
|
/** Membership test for {@link SettlementKind} — the word crosses process boundaries on `tool_end`, so a
|
|
37
45
|
* consumer validating one it received must not hand-roll the set. */
|
|
@@ -49,9 +57,9 @@ export declare function isSettlementKind(v: unknown): v is SettlementKind;
|
|
|
49
57
|
*
|
|
50
58
|
* @contract settlement.single_mint — every `Settlement` kind is composed by CORE at the site whose wait
|
|
51
59
|
* ended (the ask resolver's arms, the approval factory's arms, the decide lane over a host's decision
|
|
52
|
-
* FACTS). A host never mints a kind: a synchronous approver reports `"human"`/`"timeout"` on its
|
|
53
|
-
* and a durable decide reports `hostDecision.decidedBy` (`"person"`/`"sla_timeout"`) — facts
|
|
54
|
-
* into the word. Nothing on the caller-composable policy face (`PermissionResult`) carries a settlement,
|
|
60
|
+
* FACTS). A host never mints a kind: a synchronous approver reports `"human"`/`"timeout"`/`"policy"` on its
|
|
61
|
+
* outcome and a durable decide reports `hostDecision.decidedBy` (`"person"`/`"sla_timeout"`/`"policy"`) — facts
|
|
62
|
+
* core turns into the word. Nothing on the caller-composable policy face (`PermissionResult`) carries a settlement,
|
|
55
63
|
* so a policy cannot self-declare one; the engine's own settlement sites that hand a settled decision
|
|
56
64
|
* back through a policy fold attest it through {@link engineSettlementOf}.
|
|
57
65
|
*/
|
|
@@ -87,7 +95,7 @@ export type Settlement = {
|
|
|
87
95
|
};
|
|
88
96
|
when: number;
|
|
89
97
|
} | {
|
|
90
|
-
kind: "no_approver" | "approver_unavailable" | "approver_error" | "approver_contract" | "presentation_failed" | "blanket_allow_refused" | "task_aborted";
|
|
98
|
+
kind: "no_approver" | "approver_unavailable" | "approver_error" | "approver_contract" | "presentation_failed" | "blanket_allow_refused" | "policy_refused" | "task_aborted";
|
|
91
99
|
who: {
|
|
92
100
|
party: "none";
|
|
93
101
|
};
|
|
@@ -132,10 +140,51 @@ export declare const SETTLEMENT_IS_REFUSAL: {
|
|
|
132
140
|
readonly approver_contract: true;
|
|
133
141
|
readonly presentation_failed: true;
|
|
134
142
|
readonly blanket_allow_refused: true;
|
|
143
|
+
readonly policy_refused: true;
|
|
135
144
|
readonly task_aborted: true;
|
|
136
145
|
};
|
|
137
146
|
/** Compile-time fence: `never` while every settlement kind has a refusal row. */
|
|
138
147
|
export type SettlementRefusalTableCoversEveryKind = AssertAllKeysHandled<Exclude<SettlementKind, keyof typeof SETTLEMENT_IS_REFUSAL>>;
|
|
148
|
+
/**
|
|
149
|
+
* Which settlement kinds are a refusal NOBODY JUDGED — the wait ended because this run has no one who
|
|
150
|
+
* could answer the question, not because someone considered it and said no. The classifier's
|
|
151
|
+
* denial-limit fallback reads it: that fallback exists to bound a classifier that would otherwise deny
|
|
152
|
+
* without end, and its last resort is to STOP the run rather than let the model keep re-asking a
|
|
153
|
+
* question nothing in this deployment can answer.
|
|
154
|
+
*
|
|
155
|
+
* It is a TABLE and not a condition for the reason the sibling above is: this question used to be a
|
|
156
|
+
* hand-written two-word disjunction at one site and a three-word one at another, so a settlement word
|
|
157
|
+
* added later answered it by falling out of both — which is exactly what `policy_refused` did on the
|
|
158
|
+
* commit that introduced it (a headless lane that refused by policy stopped reaching the stop seat that
|
|
159
|
+
* the same lane reached when it refused by returning a bare `false`).
|
|
160
|
+
*
|
|
161
|
+
* The rows that say `false` each say something:
|
|
162
|
+
* - the two human words and the window words — someone WAS asked; a person answered, or their window ran
|
|
163
|
+
* out. The run is not out of judges.
|
|
164
|
+
* - `approver_unavailable` — a ROUTING answer, not a posture: the main gate owns that arm (it may park
|
|
165
|
+
* the ask on a durable queue instead), and the two sites that treat it as headless anyway say so at
|
|
166
|
+
* their own call, because they are the ones with no park route left.
|
|
167
|
+
* - `approver_error` / `approver_contract` / `presentation_failed` — the approver was there and the
|
|
168
|
+
* engine could not use it. A defect to report, not a deployment with nobody home.
|
|
169
|
+
* - `task_aborted` — the run ended; nothing is escalating anywhere.
|
|
170
|
+
*/
|
|
171
|
+
export declare const SETTLEMENT_NOBODY_JUDGED: {
|
|
172
|
+
readonly human_allowed: false;
|
|
173
|
+
readonly human_refused: false;
|
|
174
|
+
readonly approval_window_expired: false;
|
|
175
|
+
readonly denial_limit_window_expired: false;
|
|
176
|
+
readonly park_sla_expired: false;
|
|
177
|
+
readonly no_approver: true;
|
|
178
|
+
readonly approver_unavailable: false;
|
|
179
|
+
readonly approver_error: false;
|
|
180
|
+
readonly approver_contract: false;
|
|
181
|
+
readonly presentation_failed: false;
|
|
182
|
+
readonly blanket_allow_refused: true;
|
|
183
|
+
readonly policy_refused: true;
|
|
184
|
+
readonly task_aborted: false;
|
|
185
|
+
};
|
|
186
|
+
/** Compile-time fence: `never` while every settlement kind has a nobody-judged row. */
|
|
187
|
+
export type NobodyJudgedTableCoversEveryKind = AssertAllKeysHandled<Exclude<SettlementKind, keyof typeof SETTLEMENT_NOBODY_JUDGED>>;
|
|
139
188
|
/**
|
|
140
189
|
* WHO REFUSED a call — the layer whose verdict is the deny. The other question a deny raises, "who ASKED",
|
|
141
190
|
* is answered by {@link GateOutcome.origin} (an {@link import("./ask-origin.js").AskOrigin}); the two used
|
|
@@ -12,6 +12,7 @@ export const SETTLEMENT_KINDS = [
|
|
|
12
12
|
"approver_contract",
|
|
13
13
|
"presentation_failed",
|
|
14
14
|
"blanket_allow_refused",
|
|
15
|
+
"policy_refused",
|
|
15
16
|
"task_aborted",
|
|
16
17
|
];
|
|
17
18
|
const SETTLEMENT_KIND_SET = new Set(SETTLEMENT_KINDS);
|
|
@@ -37,8 +38,24 @@ export const SETTLEMENT_IS_REFUSAL = {
|
|
|
37
38
|
approver_contract: true,
|
|
38
39
|
presentation_failed: true,
|
|
39
40
|
blanket_allow_refused: true,
|
|
41
|
+
policy_refused: true,
|
|
40
42
|
task_aborted: true,
|
|
41
43
|
};
|
|
44
|
+
export const SETTLEMENT_NOBODY_JUDGED = {
|
|
45
|
+
human_allowed: false,
|
|
46
|
+
human_refused: false,
|
|
47
|
+
approval_window_expired: false,
|
|
48
|
+
denial_limit_window_expired: false,
|
|
49
|
+
park_sla_expired: false,
|
|
50
|
+
no_approver: true,
|
|
51
|
+
approver_unavailable: false,
|
|
52
|
+
approver_error: false,
|
|
53
|
+
approver_contract: false,
|
|
54
|
+
presentation_failed: false,
|
|
55
|
+
blanket_allow_refused: true,
|
|
56
|
+
policy_refused: true,
|
|
57
|
+
task_aborted: false,
|
|
58
|
+
};
|
|
42
59
|
export const DENIED_BY_VALUES = ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution", "read_boundary"];
|
|
43
60
|
const DENIED_BY_SET = new Set(DENIED_BY_VALUES);
|
|
44
61
|
export function isDeniedBy(v) {
|
|
@@ -121,7 +121,12 @@ function routingWriter(durable, session) {
|
|
|
121
121
|
throw e;
|
|
122
122
|
}
|
|
123
123
|
const outcome = await session.apply(scope.sessionId, { behavior: delta.behavior, rule: delta.rule, tool: delta.tool, match: delta.match, command: delta.command, add: delta.add });
|
|
124
|
-
|
|
124
|
+
const answered = outcome !== null && typeof outcome === "object" ? outcome : {};
|
|
125
|
+
const landed = answered.landed === true;
|
|
126
|
+
const sealed = answered.refused === "session_ended";
|
|
127
|
+
if (landed !== sealed)
|
|
128
|
+
return landed ? { rev: opts.expectedRev } : { refused: "session_ended", rev: opts.expectedRev };
|
|
129
|
+
throw new Error(`the session rule partition answered the ${delta.kind} with ${landed ? "both of its arms at once" : "neither of its two arms"} (landed / refused) — nothing here can say whether the authorization landed; reconcile by reading the session's rules`);
|
|
125
130
|
},
|
|
126
131
|
};
|
|
127
132
|
}
|
|
@@ -128,6 +128,42 @@ export type RuleWriteOutcome = PutResult | {
|
|
|
128
128
|
refused: "session_ended";
|
|
129
129
|
rev: number;
|
|
130
130
|
};
|
|
131
|
+
/**
|
|
132
|
+
* What a caller may conclude from one {@link RuleWriteOutcome} — the arms told apart ONCE, here.
|
|
133
|
+
*
|
|
134
|
+
* `unreadable` is the arm the union cannot spell: an `apply` answer is DATA (a foreign backend, a
|
|
135
|
+
* deserialized reply, a twin compiled against another generation), so "it was not a conflict and not a
|
|
136
|
+
* refusal" is not a proof that it landed.
|
|
137
|
+
*/
|
|
138
|
+
export type RuleWriteReading = {
|
|
139
|
+
kind: "landed";
|
|
140
|
+
rev: number;
|
|
141
|
+
sync?: RuleSyncLandingReport; /** The landing stands; its report does not. */
|
|
142
|
+
reportUnreadable?: string;
|
|
143
|
+
} | {
|
|
144
|
+
kind: "conflict";
|
|
145
|
+
rev: number;
|
|
146
|
+
} | {
|
|
147
|
+
kind: "refused";
|
|
148
|
+
refused: string;
|
|
149
|
+
}
|
|
150
|
+
/** The store ANSWERED, and nothing in the answer says what happened. Never a landing, never a
|
|
151
|
+
* confirmed nothing: the write may have committed and lost its acknowledgement, so the reading a
|
|
152
|
+
* caller renders from this arm is the indeterminate one and the reconciliation is `list()`. */
|
|
153
|
+
| {
|
|
154
|
+
kind: "unreadable";
|
|
155
|
+
why: string;
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* A revision is a COUNT the store advances, so this is the whole of what one may be. Zero is a revision:
|
|
159
|
+
* a session-scope add lands in a partition that has none of its own and the landing reports the durable
|
|
160
|
+
* revision unchanged, which is `0` for a durable bucket nothing has written yet.
|
|
161
|
+
*
|
|
162
|
+
* Exported because the acceptance kit judges the same thing about a backend's answer, and two copies of
|
|
163
|
+
* "what a revision is" is exactly the drift the contract-sentence discipline exists to stop.
|
|
164
|
+
*/
|
|
165
|
+
export declare function isRuleRevision(rev: unknown): rev is number;
|
|
166
|
+
export declare function readRuleWriteOutcome(res: RuleWriteOutcome): RuleWriteReading;
|
|
131
167
|
/** Authorization accompanying an add: the redemption that produced it. Carrying the record id makes the
|
|
132
168
|
* add's logical operation identity checkable at the backend, not just at the caller. A v1 caller
|
|
133
169
|
* carries `principal`; the local-owner path (design/182 §4.5) carries `owner` instead — never both
|