@sema-agent/core 5.38.0 → 5.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/dist/agents/send-message-tool.d.ts +8 -0
- package/dist/agents/send-message-tool.js +8 -0
- package/dist/agents/teacher.js +9 -3
- package/dist/agents/verify.js +9 -3
- package/dist/core/hooks.d.ts +23 -0
- package/dist/core/hooks.js +45 -4
- package/dist/core/mailbox-store.d.ts +39 -0
- package/dist/core/mailbox-store.js +9 -0
- package/dist/core/permission-rule-consent.d.ts +27 -4
- package/dist/core/permission-rule-consent.js +29 -4
- package/dist/core/permission-rule-model.d.ts +7 -1
- package/dist/core/runner/prepare-task.js +11 -3
- package/dist/core/sensitive-path-policy.js +3 -3
- package/dist/core/store-contracts/mailbox-store-contract.d.ts +29 -1
- package/dist/core/store-contracts/mailbox-store-contract.js +78 -0
- package/dist/core/types.d.ts +21 -0
- package/dist/core/write-protect.d.ts +93 -0
- package/dist/core/write-protect.js +194 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/orchestration/governance-baseline-validity.d.ts +44 -0
- package/dist/orchestration/governance-baseline-validity.js +55 -0
- package/dist/orchestration/run-workflow-tool.js +33 -8
- package/dist/orchestration/workflow-script-runner.js +9 -4
- package/dist/tools/fs/read-deny.d.ts +15 -5
- package/dist/tools/fs/read-deny.js +33 -12
- package/dist/tools/fs/safety.d.ts +4 -1
- package/dist/tools/fs/safety.js +4 -2
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +13 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,89 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.39.0 — 2026-08-17
|
|
4
|
+
|
|
5
|
+
No BREAKING changes. Two behavioral narrowings disclosed below (write-protection default table;
|
|
6
|
+
peer messages join the permission chain) plus one governance-config narrowing (malformed workflow
|
|
7
|
+
baseline slots refuse at mount instead of silently mounting ungoverned).
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- #286 (#279) — the default-on WRITE-protection table (CC 2.1.233 DANGEROUS_* parity): a Write /
|
|
12
|
+
Edit / NotebookEdit whose target lands on a named row (shell/profile rc files, VCS hook and config
|
|
13
|
+
paths, editor/tool trust files — basename, segment and segment-run row kinds; case-folded, win32
|
|
14
|
+
spellings and trailing-dot/space component aliases included) is DEMOTED TO ASK at the tool gate
|
|
15
|
+
when it would otherwise auto-allow. **Narrowing**: in 5.38.0 these writes rode a surviving allow
|
|
16
|
+
silently. The tighten demotes to ask (classifier / blanket `onAsk` / persisted rules can still
|
|
17
|
+
clear it) and never blocks reads; ONE consumer arm denies instead — an approved edit the
|
|
18
|
+
restriction chain rewrites onto a table row after the human looked is refused fail-closed, since
|
|
19
|
+
minting a fresh ask there would hand a pathological rewriter an unbounded approval treadmill.
|
|
20
|
+
Whole-table replacement seat `RunnerDeps.writeProtectedPaths`
|
|
21
|
+
(`[]` disables; a non-empty list replaces the default table; garbage rows refuse loudly, including
|
|
22
|
+
spellings the candidate fold would orphan). New exports (admin face): `WRITE_PROTECTED_DEFAULT_TABLE`,
|
|
23
|
+
`resolveWriteProtectedTable`, `compileWriteProtection` + entry/row/kind/hit/matcher types.
|
|
24
|
+
- design/276 — peer messages join the permission adjudication chain: in auto mode, `SendMessage` is
|
|
25
|
+
routed through the classifier (peer-referral tighten, third member of the post-fold tighten
|
|
26
|
+
family), and the two ancestor sandbox-admission arms stop auto-allowing an inherited
|
|
27
|
+
`sandbox_local` ask on SendMessage. **Narrowing**: a peer message that previously rode an ambient
|
|
28
|
+
allow now asks; the referral ask carries no `requiresRealApproval`, so classifier / blanket
|
|
29
|
+
`onAsk` / persisted rules can clear it.
|
|
30
|
+
- Mailbox pre-delete contract half ([4194] ruling (b)): enqueue against a recipient a deletion
|
|
31
|
+
cascade has already tombstoned is a CODED refusal — new exports `MAILBOX_TOMBSTONED_RECIPIENT_CODE`
|
|
32
|
+
(`"mailbox.recipient_tombstoned"`), `MailboxStoreError`, and the `mailboxTombstonedRecipientContract`
|
|
33
|
+
acceptance-kit leg (optional contract clause: only backends that can see recipient lifecycle carry
|
|
34
|
+
it; the refusal is per-recipient and enqueue-only).
|
|
35
|
+
- CC settings import — the preview reports `uncovered.denyAskBuckets: { deny, ask }`: how many
|
|
36
|
+
deny/ask entries the import leaves in place across the layers it reads (only the allow bucket is
|
|
37
|
+
imported; by-design-not-imported and unnoticed no longer look identical).
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- Workflow governance baseline hardening (5.39 rescan window): a non-object `base` slot
|
|
42
|
+
(null / undefined / primitive / array — the shapes JSON/DB schema drift mints) is a CODED mount
|
|
43
|
+
refusal (`config.invalid_governance_baseline`) instead of silently mounting every script-spawned
|
|
44
|
+
child with ZERO inherited governance (5.38.0's raw spread accepted these spellings silently);
|
|
45
|
+
`worktreeBase: null` folds to its documented absence, any other non-object shape there refuses
|
|
46
|
+
with the same code. Face-list fields (`excludeTools`/`deferTools`/`alwaysLoadTools`) validate as
|
|
47
|
+
string arrays at mount (`config.invalid_tool_name_set`), and a null face field reads as absent in
|
|
48
|
+
BOTH slots (a null overlay field no longer silently remounts what the deployment excluded).
|
|
49
|
+
- **The win32 component alias is closed on every path face, by one uniform rule.** `.bashrc.` and
|
|
50
|
+
`.bashrc ` name the SAME file as `.bashrc` on the wire (the win32 file namespace strips trailing
|
|
51
|
+
dots and spaces from every component), and `. ` / `.. ` are that namespace's spellings of `.` and
|
|
52
|
+
`..`. Pre-fix, `.aws.\credentials` missed the built-in read-deny set entirely on the purely
|
|
53
|
+
lexical legs — the bash-classify deny probe resolves operands with zero I/O by contract, so
|
|
54
|
+
nothing collapsed the alias later and the read took the auto-allow lane.
|
|
55
|
+
Every lexical path face (the write-protection matcher, the read-deny segment engine and its
|
|
56
|
+
sensitive-path consumer) now judges **two views** — components verbatim, and win32-normalized —
|
|
57
|
+
and protects if **either** hits. A lexical matcher cannot know the target platform (execution
|
|
58
|
+
envs may be remote, and win32 accepts forward-slash spellings, so separator gating carries
|
|
59
|
+
nothing), so picking one interpretation is always wrong for the other; the two errors are not
|
|
60
|
+
symmetric, since a missed protection is silent while an over-match is one extra question.
|
|
61
|
+
**The win32 view is skipped on one reported fact**: `canonicalizeTarget`'s realpath arm now returns
|
|
62
|
+
`aliasResolved`, meaning the real filesystem produced that key, so any component alias in the
|
|
63
|
+
spelling was already collapsed by the filesystem itself — a genuinely-resolved POSIX target keeps
|
|
64
|
+
its own reading and a real `.aws.` directory stays readable and writable. The arms that resolve
|
|
65
|
+
NOTHING do not report it and keep both views: UNC keys (minted with zero probes for liveness) and
|
|
66
|
+
not-yet-existing targets (whose tail is rejoined verbatim) — which is where the planting vector
|
|
67
|
+
lives. Unreported is the fail-closed default, so a caller that forgets to pass it loses precision,
|
|
68
|
+
never protection.
|
|
69
|
+
**Residual cost**: a target the filesystem has NOT resolved still over-matches — writing a
|
|
70
|
+
not-yet-created file inside a legal POSIX `.aws.` directory asks (write face) or refuses
|
|
71
|
+
(sensitive-path policy). Trailing-dot table ROWS stay legal (they match the POSIX file of exactly
|
|
72
|
+
that name; on win32 that spelling *is* the stripped name, which the ordinary row already covers).
|
|
73
|
+
**Known gap, ticketed not fixed**: the ripgrep exclusion globs do not fold, so a Grep result can
|
|
74
|
+
differ by whether ripgrep is installed (#303).
|
|
75
|
+
- The self-orchestration gates ask the mount's question, not just "is the field there".
|
|
76
|
+
`workflowsCapability` tested `!== undefined` while the mount site tested truthiness, so
|
|
77
|
+
`workflowGovernanceBaseline: null` announced workflows with `run_workflow` unmounted and
|
|
78
|
+
suppressed the fail-closed operator warning; and once the mount began refusing an unusable `base`
|
|
79
|
+
slot, `{ base: null }` / `{}` announced workflows that then hard-failed every opted-in task. One
|
|
80
|
+
predicate now answers for all of them, and it reads the `base` slot — so an unusable baseline is
|
|
81
|
+
capability-false, inactive, and named in the operator warning instead of failing at run time.
|
|
82
|
+
- Internal verify/teacher leg spec inheritance stops spreading a bare-string
|
|
83
|
+
`excludeTools`/`deferTools`/`alwaysLoadTools` into inert single-character names — malformed shapes
|
|
84
|
+
ride verbatim so the leg's own prepare judges them (coded doors for the three public seats are
|
|
85
|
+
tracked separately).
|
|
86
|
+
|
|
3
87
|
## 5.38.0 — 2026-08-17
|
|
4
88
|
|
|
5
89
|
No BREAKING changes. One behavioral narrowing (default task-scaffold trim on strong claude models)
|
|
@@ -144,6 +144,14 @@ export declare const SEND_MESSAGE_SUMMARY_MAX = 200;
|
|
|
144
144
|
* four re-derivations into a SINGLE call site, immediately after the gate that makes the argument
|
|
145
145
|
* non-empty — so this helper now has exactly one caller in the tool body. */
|
|
146
146
|
export declare function clipSendMessageSummary(raw: string): string;
|
|
147
|
+
/**
|
|
148
|
+
* design/276 (direct-mount guidance): the engine's first-party auto-mount declares this tool's safety
|
|
149
|
+
* axes itself (`effect:"write"`, explicit `egress:false`). A host mounting this factory's product via
|
|
150
|
+
* `spec.tools` suppresses that auto-mount, so its own ToolSpec declaration is what the safety scan
|
|
151
|
+
* reads — declare `effect:"write"` there (a message is repeat-unsafe: replay = second delivery /
|
|
152
|
+
* second revive). The auto-mode peer-referral tighten keys on the WIRE NAME and applies to a direct
|
|
153
|
+
* mount all the same.
|
|
154
|
+
*/
|
|
147
155
|
export declare function createSendMessageTool(opts: SendMessageToolOptions): import("../core/types.js").AgentTool<Type.TObject<{
|
|
148
156
|
to: Type.TString;
|
|
149
157
|
message: Type.TString;
|
|
@@ -2,6 +2,7 @@ import { Type } from "typebox";
|
|
|
2
2
|
import { defineTool } from "../core/tools.js";
|
|
3
3
|
import { normalizeAgentName, DURABLE_AGENT_HANDLE_RE, DURABLE_AGENT_HEARTBEAT_MS } from "../core/task-registry.js";
|
|
4
4
|
import { canAccessAgentRecord, clearRevivedRowTerminalPayload } from "../core/background-agent-store.js";
|
|
5
|
+
import { MAILBOX_TOMBSTONED_RECIPIENT_CODE } from "../core/mailbox-store.js";
|
|
5
6
|
import { escapeAttributeValue, escapeEnvelopeTag, isObserverTaskId, OBSERVER_SENDMESSAGE_SENDER_REFUSAL, OBSERVER_SENDMESSAGE_TARGET_REFUSAL, } from "./observer.js";
|
|
6
7
|
import { SUBAGENT_RESUME_CAP, SubagentRetainLedger, getSessionRetainLedger } from "./retain-ledger.js";
|
|
7
8
|
import { createSubagentResume } from "./subagent.js";
|
|
@@ -404,6 +405,13 @@ export function createSendMessageTool(opts) {
|
|
|
404
405
|
}
|
|
405
406
|
catch (e) {
|
|
406
407
|
await rollback();
|
|
408
|
+
if (e?.code === MAILBOX_TOMBSTONED_RECIPIENT_CODE) {
|
|
409
|
+
return {
|
|
410
|
+
content: `Message not sent: that agent is being deleted — its mailbox no longer accepts messages, so nothing was parked and it was not revived. Do not retry; launch a new agent with the needed context instead.`,
|
|
411
|
+
details: { error: "mailbox_recipient_deleted", to },
|
|
412
|
+
isError: true,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
407
415
|
return {
|
|
408
416
|
content: `Message not sent: the durable mailbox refused the message (${e instanceof Error ? e.message : String(e)}) — nothing was parked and the agent was not revived. ${DEDUP_RETRY_NOTE}`,
|
|
409
417
|
details: { error: "mailbox_failed", to },
|
package/dist/agents/teacher.js
CHANGED
|
@@ -183,9 +183,15 @@ async function runTeacherCore(runner, studentSpec, teacher) {
|
|
|
183
183
|
...(studentSpec.readFace === "roots" ? { readFace: "roots" } : {}),
|
|
184
184
|
...(studentSpec.readDenyPatterns !== undefined ? { readDenyPatterns: [...studentSpec.readDenyPatterns] } : {}),
|
|
185
185
|
...(studentSpec.interactiveTools === false ? { interactiveTools: false } : {}),
|
|
186
|
-
...(studentSpec.excludeTools !== undefined
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
...(studentSpec.excludeTools !== undefined
|
|
187
|
+
? { excludeTools: Array.isArray(studentSpec.excludeTools) ? [...studentSpec.excludeTools] : studentSpec.excludeTools }
|
|
188
|
+
: {}),
|
|
189
|
+
...(studentSpec.deferTools !== undefined
|
|
190
|
+
? { deferTools: Array.isArray(studentSpec.deferTools) ? [...studentSpec.deferTools] : studentSpec.deferTools }
|
|
191
|
+
: {}),
|
|
192
|
+
...(studentSpec.alwaysLoadTools !== undefined
|
|
193
|
+
? { alwaysLoadTools: Array.isArray(studentSpec.alwaysLoadTools) ? [...studentSpec.alwaysLoadTools] : studentSpec.alwaysLoadTools }
|
|
194
|
+
: {}),
|
|
189
195
|
...(studentSpec.restoreGatedTools !== undefined
|
|
190
196
|
? { restoreGatedTools: Array.isArray(studentSpec.restoreGatedTools) ? [...studentSpec.restoreGatedTools] : studentSpec.restoreGatedTools }
|
|
191
197
|
: {}),
|
package/dist/agents/verify.js
CHANGED
|
@@ -146,9 +146,15 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
|
|
|
146
146
|
limits: { ...(specBase.limits?.maxWalltimeMs !== undefined ? { maxWalltimeMs: specBase.limits.maxWalltimeMs } : {}) },
|
|
147
147
|
getApiKeyAndHeaders: specBase.getApiKeyAndHeaders,
|
|
148
148
|
...(specBase.principal !== undefined ? { principal: specBase.principal } : {}),
|
|
149
|
-
...(specBase.excludeTools !== undefined
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
...(specBase.excludeTools !== undefined
|
|
150
|
+
? { excludeTools: Array.isArray(specBase.excludeTools) ? [...specBase.excludeTools] : specBase.excludeTools }
|
|
151
|
+
: {}),
|
|
152
|
+
...(specBase.deferTools !== undefined
|
|
153
|
+
? { deferTools: Array.isArray(specBase.deferTools) ? [...specBase.deferTools] : specBase.deferTools }
|
|
154
|
+
: {}),
|
|
155
|
+
...(specBase.alwaysLoadTools !== undefined
|
|
156
|
+
? { alwaysLoadTools: Array.isArray(specBase.alwaysLoadTools) ? [...specBase.alwaysLoadTools] : specBase.alwaysLoadTools }
|
|
157
|
+
: {}),
|
|
152
158
|
...(specBase.restoreGatedTools !== undefined
|
|
153
159
|
? { restoreGatedTools: Array.isArray(specBase.restoreGatedTools) ? [...specBase.restoreGatedTools] : specBase.restoreGatedTools }
|
|
154
160
|
: {}),
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -938,6 +938,29 @@ export interface ToolGateInput {
|
|
|
938
938
|
* per-tool mark — attributes a tighten-deny to `source:"shellGate"` instead of `"safety"`.
|
|
939
939
|
*/
|
|
940
940
|
shellGated?: boolean;
|
|
941
|
+
/**
|
|
942
|
+
* design/276 (CC 2.1.233 P11 arm 4): true when the called tool is the peer-message verb — a message
|
|
943
|
+
* to another agent (`SendMessage`). ENGINE-FILLED from the wire name at the caller, never a
|
|
944
|
+
* configuration knob; a caller's same-named shadow tool is keyed too (for a tighten, over-asking is
|
|
945
|
+
* the fail-safe direction). Under an armed {@link autoMode} the gate tightens a surviving `allow` to
|
|
946
|
+
* `ask` (the peer-referral tighten below) so the message passes the classifier's eye — its prompt
|
|
947
|
+
* already carries the Multi-Agent Coordination exemption, so the ordinary teammate message resolves
|
|
948
|
+
* allow and the injection-shaped one is what this member exists to catch. Absent/false, or with no
|
|
949
|
+
* armed auto mode, the decision path is byte-identical. Per-run semantic: armed by THIS run's
|
|
950
|
+
* resolved caps, never inherited down the delegation chain (CC parity — the mode predicate reads the
|
|
951
|
+
* SENDING session's own mode).
|
|
952
|
+
*/
|
|
953
|
+
peerMessage?: boolean;
|
|
954
|
+
/**
|
|
955
|
+
* backlog #286 (CC 2.1.233 `DANGEROUS_*` parity): the write-protection judge — ENGINE-BUILT from
|
|
956
|
+
* the deployment's table seat by `createWriteProtectionCheck` (write-protect.ts), never a
|
|
957
|
+
* deployment callback (the deployment authors table ROWS, which are validated loudly at compile;
|
|
958
|
+
* the judge itself is pure and synchronous, so it is trusted here like the `egress` mark). Judges
|
|
959
|
+
* the FINAL args of a path-confinable write tool; a hit demotes a surviving `allow` to `ask`
|
|
960
|
+
* (the write-protection tighten below). Absent ⇒ the deployment replaced the table with `[]` (or
|
|
961
|
+
* the caller runs the gate without one) and the decision path is byte-identical.
|
|
962
|
+
*/
|
|
963
|
+
writeProtectionCheck?: (toolName: string, args: unknown) => import("./write-protect.js").WriteProtectedHit | null;
|
|
941
964
|
/**
|
|
942
965
|
* design/143 批2 ([672]-A, CC 2.1.207 auto mode): when present, a surviving `ask` is routed to the
|
|
943
966
|
* small-model policy CLASSIFIER before any human/durable resolution:
|
package/dist/core/hooks.js
CHANGED
|
@@ -398,6 +398,25 @@ export async function runToolGate(input) {
|
|
|
398
398
|
denySource = input.shellGated === true ? "shellGate" : "safety";
|
|
399
399
|
}
|
|
400
400
|
}
|
|
401
|
+
if (input.peerMessage === true && decision.action === "allow" && input.autoMode !== undefined && !input.autoMode.decider.breakerOpen()) {
|
|
402
|
+
decision = {
|
|
403
|
+
action: "ask",
|
|
404
|
+
message: `tool "${toolName}" sends a message to another agent — routed for classifier review in auto mode`,
|
|
405
|
+
decisionReason: "safety",
|
|
406
|
+
};
|
|
407
|
+
denySource = "safety";
|
|
408
|
+
}
|
|
409
|
+
if (decision.action === "allow" && input.writeProtectionCheck !== undefined) {
|
|
410
|
+
const writeProtectedHit = input.writeProtectionCheck(toolName, policyRewrite !== undefined ? policyRewrite : currentInput);
|
|
411
|
+
if (writeProtectedHit !== null) {
|
|
412
|
+
decision = {
|
|
413
|
+
action: "ask",
|
|
414
|
+
message: `tool "${toolName}" writes to a write-protected path (table entry "${writeProtectedHit.name}") — explicit approval required`,
|
|
415
|
+
decisionReason: "safety",
|
|
416
|
+
};
|
|
417
|
+
denySource = "safety";
|
|
418
|
+
}
|
|
419
|
+
}
|
|
401
420
|
let orgRealApprovalRequired = false;
|
|
402
421
|
let orgAskOrigin;
|
|
403
422
|
let orgTightenCount = 0;
|
|
@@ -573,6 +592,7 @@ export async function runToolGate(input) {
|
|
|
573
592
|
decision.decisionReason !== "hook" &&
|
|
574
593
|
hookAsk === undefined &&
|
|
575
594
|
input.egress !== true &&
|
|
595
|
+
input.peerMessage !== true &&
|
|
576
596
|
input.sandboxAdmission.boundaryCapable !== true) {
|
|
577
597
|
const recorded = input.sandboxAdmission.askClassesOf(toolCallId);
|
|
578
598
|
const admissible = recorded !== undefined && recorded.length > 0 && recorded.every((r) => r.cls === "sandbox_local");
|
|
@@ -655,6 +675,7 @@ export async function runToolGate(input) {
|
|
|
655
675
|
if (decision.action === "allow" && decision.updatedInput !== undefined) {
|
|
656
676
|
let editArgs = decision.updatedInput;
|
|
657
677
|
let editDenied;
|
|
678
|
+
let editRewrittenSinceHuman = false;
|
|
658
679
|
for (let round = 0;; round++) {
|
|
659
680
|
if (round >= 3) {
|
|
660
681
|
editDenied = {
|
|
@@ -691,8 +712,10 @@ export async function runToolGate(input) {
|
|
|
691
712
|
denySource = "hook";
|
|
692
713
|
break;
|
|
693
714
|
}
|
|
694
|
-
if (hr.updatedInput !== undefined)
|
|
715
|
+
if (hr.updatedInput !== undefined) {
|
|
695
716
|
editArgs = hr.updatedInput;
|
|
717
|
+
editRewrittenSinceHuman = true;
|
|
718
|
+
}
|
|
696
719
|
}
|
|
697
720
|
}
|
|
698
721
|
if (!adjudicate && input.orgRules === undefined)
|
|
@@ -714,8 +737,10 @@ export async function runToolGate(input) {
|
|
|
714
737
|
denySource = "policy";
|
|
715
738
|
break;
|
|
716
739
|
}
|
|
717
|
-
if (recheck.updatedInput !== undefined)
|
|
740
|
+
if (recheck.updatedInput !== undefined) {
|
|
718
741
|
editArgs = recheck.updatedInput;
|
|
742
|
+
editRewrittenSinceHuman = true;
|
|
743
|
+
}
|
|
719
744
|
const orgTightensBefore = orgTightenCount;
|
|
720
745
|
recheck = await applyOrgLayer(recheck, editArgs);
|
|
721
746
|
const orgRaisedThisRound = orgTightenCount > orgTightensBefore;
|
|
@@ -724,12 +749,16 @@ export async function runToolGate(input) {
|
|
|
724
749
|
break;
|
|
725
750
|
}
|
|
726
751
|
if (recheck.action === "allow") {
|
|
727
|
-
if (recheck.updatedInput !== undefined)
|
|
752
|
+
if (recheck.updatedInput !== undefined) {
|
|
728
753
|
editArgs = recheck.updatedInput;
|
|
754
|
+
editRewrittenSinceHuman = true;
|
|
755
|
+
}
|
|
729
756
|
break;
|
|
730
757
|
}
|
|
731
|
-
if (recheck.updatedInput !== undefined)
|
|
758
|
+
if (recheck.updatedInput !== undefined) {
|
|
732
759
|
editArgs = recheck.updatedInput;
|
|
760
|
+
editRewrittenSinceHuman = true;
|
|
761
|
+
}
|
|
733
762
|
const rr = await resolveAsk({ ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) }, { toolName, args: editArgs, toolCallId });
|
|
734
763
|
resolvedApprover = rr.action !== "ask" ? rr.approver : undefined;
|
|
735
764
|
if (rr.action !== "allow") {
|
|
@@ -741,9 +770,21 @@ export async function runToolGate(input) {
|
|
|
741
770
|
if (rr.updatedInput === undefined) {
|
|
742
771
|
if (rr.presentedInput !== undefined)
|
|
743
772
|
editArgs = rr.presentedInput;
|
|
773
|
+
editRewrittenSinceHuman = false;
|
|
744
774
|
break;
|
|
745
775
|
}
|
|
746
776
|
editArgs = rr.updatedInput;
|
|
777
|
+
editRewrittenSinceHuman = false;
|
|
778
|
+
}
|
|
779
|
+
if (editDenied === undefined && editRewrittenSinceHuman && input.writeProtectionCheck !== undefined) {
|
|
780
|
+
const editHit = input.writeProtectionCheck(toolName, editArgs);
|
|
781
|
+
if (editHit !== null) {
|
|
782
|
+
editDenied = {
|
|
783
|
+
action: "deny",
|
|
784
|
+
message: `the approved edit for "${toolName}" was rewritten by the restriction chain onto a write-protected path (table entry "${editHit.name}") that no approval covers — denied fail-closed; re-submit the edited action directly`,
|
|
785
|
+
};
|
|
786
|
+
denySource = "safety";
|
|
787
|
+
}
|
|
747
788
|
}
|
|
748
789
|
decision = editDenied ?? { ...decision, updatedInput: editArgs };
|
|
749
790
|
}
|
|
@@ -13,6 +13,28 @@ export interface MailboxMessage {
|
|
|
13
13
|
* cross-engine records sit outside the guard's promise domain by ruling. */
|
|
14
14
|
hopChain?: string[];
|
|
15
15
|
}
|
|
16
|
+
/** The enqueue refusal code of the pre-delete clause (see {@link MailboxStore} and
|
|
17
|
+
* {@link MailboxStoreError}) — the ONE place it is spelled, so an out-of-repo store twin imports it
|
|
18
|
+
* instead of value-copying the string (same posture as `STALE_RUNNING_REAP_ATTRIBUTION`: a shared
|
|
19
|
+
* symbol makes drift impossible rather than merely caught). */
|
|
20
|
+
export declare const MAILBOX_TOMBSTONED_RECIPIENT_CODE = "mailbox.recipient_tombstoned";
|
|
21
|
+
/**
|
|
22
|
+
* Typed store error so callers branch on `code` (mirrors `BackgroundAgentStoreError`).
|
|
23
|
+
*
|
|
24
|
+
* `recipient_tombstoned` is the ENQUEUE refusal a backend raises when the recipient it addresses is
|
|
25
|
+
* in a deployment's PRE-DELETE state — the window a retention/deletion cascade opens when it has
|
|
26
|
+
* decided a session (and every row hanging off it) is going away but has not finished removing the
|
|
27
|
+
* rows. An `append` accepted in that window returns a seq to the sender — a durable receipt — for a
|
|
28
|
+
* message the cascade then deletes before any consumer could ever lease it. The refusal is what
|
|
29
|
+
* turns that into a fact the sender can act on.
|
|
30
|
+
*
|
|
31
|
+
* A backend may raise the same code with a plain `Error` carrying `.code`; consumers branch on the
|
|
32
|
+
* string, not on this class (a cross-process/out-of-repo store cannot hand back an instance).
|
|
33
|
+
*/
|
|
34
|
+
export declare class MailboxStoreError extends Error {
|
|
35
|
+
readonly code: typeof MAILBOX_TOMBSTONED_RECIPIENT_CODE;
|
|
36
|
+
constructor(code: typeof MAILBOX_TOMBSTONED_RECIPIENT_CODE, message: string);
|
|
37
|
+
}
|
|
16
38
|
/** A leased batch: the messages a claim winner owns for delivery, plus the ack cursor. */
|
|
17
39
|
export interface MailboxLease {
|
|
18
40
|
messages: MailboxMessage[];
|
|
@@ -55,8 +77,25 @@ export interface MailboxAppendMessage {
|
|
|
55
77
|
* is ADVISORY: a drop fault never blocks the reap, and a box orphaned that way still ages out here.
|
|
56
78
|
* `drop` therefore has a real caller — an implementation that stubs it strands mailboxes until the
|
|
57
79
|
* age policy catches them.
|
|
80
|
+
* - PRE-DELETE STATE (optional capability, additive contract extension): a backend that can SEE its
|
|
81
|
+
* recipient's lifecycle — one whose rows live alongside the session rows a retention/deletion
|
|
82
|
+
* cascade removes — must refuse `append` for a recipient the cascade has already tombstoned,
|
|
83
|
+
* CODED: an `Error` whose `code` is `"mailbox.recipient_tombstoned"` ({@link MailboxStoreError} is
|
|
84
|
+
* the bundled shape; consumers branch on the string). Accepting is the failure this clause names:
|
|
85
|
+
* `append` is durable-first, so the returned seq is a receipt for a message the cascade deletes
|
|
86
|
+
* before any consumer can lease it — a delivery promised to the sender and kept to nobody, which
|
|
87
|
+
* no other face reports. The refusal is per RECIPIENT and enqueue-only: a tombstone belongs to one
|
|
88
|
+
* `(scope, handle)` — the same two-part identity every other method here is keyed by, so it says
|
|
89
|
+
* nothing about a sibling handle NOR about the same handle under another scope — and it is not a
|
|
90
|
+
* wipe (whatever is already parked stays under the same lease/ack rules until the cascade removes
|
|
91
|
+
* the box). A backend with NO view of that
|
|
92
|
+
* lifecycle (the two bundled ones, the CC inbox adapter) has nothing to refuse and keeps accepting
|
|
93
|
+
* — the clause fixes the SPELLING of the refusal, so a deployment reads one code instead of a
|
|
94
|
+
* per-backend dialect. Acceptance kit: `mailboxTombstonedRecipientContract`.
|
|
58
95
|
*/
|
|
59
96
|
export interface MailboxStore {
|
|
97
|
+
/** Durably park one message. Refuses `"mailbox.recipient_tombstoned"` when the backend can see
|
|
98
|
+
* that its recipient is in the deployment's pre-delete state (see the interface notes above). */
|
|
60
99
|
append(scope: string, handle: string, msg: MailboxAppendMessage): Promise<number>;
|
|
61
100
|
claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>;
|
|
62
101
|
ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<void>;
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import { assertRetentionPolicy } from "./retention-policy.js";
|
|
2
|
+
export const MAILBOX_TOMBSTONED_RECIPIENT_CODE = "mailbox.recipient_tombstoned";
|
|
3
|
+
export class MailboxStoreError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = "MailboxStoreError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
2
11
|
export function newestSentAt(messages) {
|
|
3
12
|
let newest;
|
|
4
13
|
for (const m of messages) {
|
|
@@ -285,9 +285,9 @@ export interface CcImportLayer {
|
|
|
285
285
|
/**
|
|
286
286
|
* What the import WOULD do, shown before anyone confirms.
|
|
287
287
|
*
|
|
288
|
-
* `uncovered` is a
|
|
288
|
+
* `uncovered` is a fixed-key record, not a list: everything this version does not import is named in the
|
|
289
289
|
* TYPE, so an empty list, a missing member or a duplicate is not expressible. A preview that reported only
|
|
290
|
-
*
|
|
290
|
+
* what it read would be claiming completeness it does not have.
|
|
291
291
|
*/
|
|
292
292
|
export interface ImportPreview {
|
|
293
293
|
candidates: RuleCandidate[];
|
|
@@ -303,6 +303,26 @@ export interface ImportPreview {
|
|
|
303
303
|
uncovered: {
|
|
304
304
|
flagSettings: "not-imported-v1";
|
|
305
305
|
policySettings: "not-imported-v1";
|
|
306
|
+
/**
|
|
307
|
+
* The deny/ask buckets of the layers actually read: how many entries are SITTING THERE, across all
|
|
308
|
+
* layers, that this import deliberately leaves in place (they are the tightening direction and have
|
|
309
|
+
* their own channel — importing them through a loosening lane would be the wrong door).
|
|
310
|
+
*
|
|
311
|
+
* Always present, zero when the buckets are absent or empty: "this settings file has 14 deny entries
|
|
312
|
+
* we did not touch" and "there was nothing there" are different facts, and a key that appears only in
|
|
313
|
+
* the first case makes them indistinguishable for a reader who sees one preview. The count is of
|
|
314
|
+
* ENTRIES IN PLACE, not of a decision — nothing here is skipped-and-lost; `skipped` stays the list of
|
|
315
|
+
* allow-bucket entries that did not become candidates.
|
|
316
|
+
*
|
|
317
|
+
* A bucket that is PRESENT but not an array cannot be counted; it contributes 0 and is disclosed in
|
|
318
|
+
* `skipped` under the layer's path, so a zero is never the report for something unreadable. Same for
|
|
319
|
+
* a layer whose whole `permissions` member is not an object — every bucket in it reads as absent,
|
|
320
|
+
* which is exactly the shape that would otherwise report two clean zeros.
|
|
321
|
+
*/
|
|
322
|
+
denyAskBuckets: {
|
|
323
|
+
deny: number;
|
|
324
|
+
ask: number;
|
|
325
|
+
};
|
|
306
326
|
};
|
|
307
327
|
}
|
|
308
328
|
/** What the import ACTUALLY did — a different moment and a different contract from the preview, because
|
|
@@ -320,8 +340,11 @@ export interface ImportResult {
|
|
|
320
340
|
* Read the allow buckets of the user-editable settings layers and produce a preview plus a PENDING
|
|
321
341
|
* approval record. Nothing is stored until someone confirms that record and the batch is redeemed.
|
|
322
342
|
*
|
|
323
|
-
* Only the allow bucket is
|
|
324
|
-
* channel; importing them through a loosening lane would be the wrong door.
|
|
343
|
+
* Only the allow bucket is IMPORTED. The deny/ask buckets are the tightening direction and have their own
|
|
344
|
+
* channel; importing them through a loosening lane would be the wrong door. They are still COUNTED and
|
|
345
|
+
* reported (`preview.uncovered.denyAskBuckets`): by-design-not-imported and unnoticed look identical to
|
|
346
|
+
* the person confirming the batch, and only one of them is true here — the same reason the non-Bash allow
|
|
347
|
+
* entries are disclosed rather than dropped silently.
|
|
325
348
|
*/
|
|
326
349
|
export declare function prepareCcImport(opts: {
|
|
327
350
|
layers: CcImportLayer[];
|
|
@@ -337,12 +337,13 @@ async function applyRedemption(args) {
|
|
|
337
337
|
function originOfRecordKind(kind) {
|
|
338
338
|
return kind === "import" ? "imported-cc" : kind === "starter" ? "starter" : "user";
|
|
339
339
|
}
|
|
340
|
-
const
|
|
340
|
+
const IMPORT_UNCOVERED_LAYERS = { flagSettings: "not-imported-v1", policySettings: "not-imported-v1" };
|
|
341
341
|
export async function prepareCcImport(opts) {
|
|
342
342
|
const owner = resolveCallerOwner(opts.principal, opts.owner, "prepareCcImport");
|
|
343
343
|
const candidates = [];
|
|
344
344
|
const skipped = [];
|
|
345
345
|
const layers = [];
|
|
346
|
+
const denyAskBuckets = { deny: 0, ask: 0 };
|
|
346
347
|
for (const layer of opts.layers) {
|
|
347
348
|
let raw;
|
|
348
349
|
try {
|
|
@@ -356,14 +357,38 @@ export async function prepareCcImport(opts) {
|
|
|
356
357
|
continue;
|
|
357
358
|
}
|
|
358
359
|
layers.push({ path: layer.path, layer: layer.layer, found: true });
|
|
359
|
-
let
|
|
360
|
+
let container;
|
|
360
361
|
try {
|
|
361
|
-
|
|
362
|
+
container = JSON.parse(raw)?.permissions;
|
|
362
363
|
}
|
|
363
364
|
catch (err) {
|
|
364
365
|
skipped.push({ rule: layer.path, reason: `settings file is not valid JSON (${errText(err)})` });
|
|
365
366
|
continue;
|
|
366
367
|
}
|
|
368
|
+
let permissions;
|
|
369
|
+
if (container === undefined || container === null)
|
|
370
|
+
permissions = undefined;
|
|
371
|
+
else if (typeof container === "object" && !Array.isArray(container))
|
|
372
|
+
permissions = container;
|
|
373
|
+
else {
|
|
374
|
+
skipped.push({
|
|
375
|
+
rule: layer.path,
|
|
376
|
+
reason: `settings "permissions" is not an object — no bucket in this layer could be read, so it is reported as unread rather than as empty (it stays in the settings file either way)`,
|
|
377
|
+
});
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
for (const bucket of ["deny", "ask"]) {
|
|
381
|
+
const entries = permissions?.[bucket];
|
|
382
|
+
if (Array.isArray(entries))
|
|
383
|
+
denyAskBuckets[bucket] += entries.length;
|
|
384
|
+
else if (entries !== undefined && entries !== null) {
|
|
385
|
+
skipped.push({
|
|
386
|
+
rule: layer.path,
|
|
387
|
+
reason: `settings ${bucket} bucket is not an array — its entries could not be counted for the not-imported report (it stays in the settings file either way)`,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const allow = permissions?.allow;
|
|
367
392
|
if (!Array.isArray(allow))
|
|
368
393
|
continue;
|
|
369
394
|
const scope = layer.layer === "userSettings" ? { kind: "global" } : { kind: "project", root: layer.root };
|
|
@@ -401,7 +426,7 @@ export async function prepareCcImport(opts) {
|
|
|
401
426
|
createdAt: nowIso(opts.deps),
|
|
402
427
|
};
|
|
403
428
|
await opts.deps.approvals.create(record);
|
|
404
|
-
return { preview: { candidates, skipped, layers, uncovered:
|
|
429
|
+
return { preview: { candidates, skipped, layers, uncovered: { ...IMPORT_UNCOVERED_LAYERS, denyAskBuckets } }, approvalId: record.id };
|
|
405
430
|
}
|
|
406
431
|
export const STARTER_RULES = [
|
|
407
432
|
"Bash(ls)",
|
|
@@ -29,7 +29,13 @@
|
|
|
29
29
|
* here) and no leading-env-assignment stripping (`FOO=1 git status` simply does not match
|
|
30
30
|
* `Bash(git status:*)`) — the last two are strict-side divergences from upstream, registered as such.
|
|
31
31
|
*/
|
|
32
|
-
/** The one tool the v1 rule lane speaks for. The field exists on the rule so v2 can widen without a shape change.
|
|
32
|
+
/** The one tool the v1 rule lane speaks for. The field exists on the rule so v2 can widen without a shape change.
|
|
33
|
+
*
|
|
34
|
+
* design/276 §3.3 (doctrine, for whoever widens this set): a future `SendMessage(…)`-form rule MAY
|
|
35
|
+
* clear the peer-referral ask — that ask is classifier hesitation by construction (#144: allow rules
|
|
36
|
+
* silence the classifier's questions, never a mandated one), so a recorded human yes is exactly what
|
|
37
|
+
* clears it. The org/hook/matchedAskRule immunities stay: those conjuncts live in the gate's
|
|
38
|
+
* persisted-rule lane and do not loosen with this set. */
|
|
33
39
|
export type PersistedRuleTool = "Bash";
|
|
34
40
|
/** v1 match forms. `"wildcard"` is reserved for v2 and is not a value this version ever produces. */
|
|
35
41
|
export type PersistedRuleMatch = "exact" | "prefix";
|
|
@@ -28,8 +28,9 @@ import { CHANGED_FILES_MTIME_EPS_MS, fenceMcpServerInstructions, renderAgentList
|
|
|
28
28
|
import { inlineUntrusted } from "../untrusted-text.js";
|
|
29
29
|
import { policyAskClassOf } from "../ask-class.js";
|
|
30
30
|
import { emitTrace } from "../trace.js";
|
|
31
|
-
import { createSessionRulePolicy } from "./session-rule-policy.js";
|
|
31
|
+
import { createSessionRulePolicy, PATH_CONFINABLE_WRITE_TOOLS } from "./session-rule-policy.js";
|
|
32
32
|
import { cloneObserverInput, createHookEnvCapabilities, createPreToolUseConstraintPolicy, formatHookFeedback, mintHookInvocationIdentity, persistedRuleMandateOf, runToolGate } from "../hooks.js";
|
|
33
|
+
import { createWriteProtectionCheck } from "../write-protect.js";
|
|
33
34
|
import { orgRuleVerdictFor } from "../permission-rule-org.js";
|
|
34
35
|
import { CacheBreakDetector, toolsToFingerprintInputs } from "../cache-break-detector.js";
|
|
35
36
|
import { reservedCollisions, reservedFor } from "../../brain/request-params.js";
|
|
@@ -1413,6 +1414,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
1413
1414
|
toolEffects.set("TaskStop", "write");
|
|
1414
1415
|
tools.push(firstPartyOffload(createTaskOutputTool({ registry: defaultTaskRegistry, owner: hostTaskId, scope: taskScope, sessionId, workflowStore: deps.workflowRunStore, agentStore: deps.backgroundAgentStore, notificationWired: internals?.onTaskNotification !== undefined, oneShot: spec.oneShot, toolResultStore: offloadStore })), firstPartyOffload(createTaskStopTool({ registry: defaultTaskRegistry, owner: hostTaskId, scope: taskScope, sessionId, workflowStore: deps.workflowRunStore, agentStore: deps.backgroundAgentStore })));
|
|
1415
1416
|
if (runnerSelf && !(spec.tools ?? []).some((t) => t.name === SEND_MESSAGE_TOOL_NAME)) {
|
|
1417
|
+
toolEffects.set(SEND_MESSAGE_TOOL_NAME, "write");
|
|
1418
|
+
axisExplicitNegatives.set(SEND_MESSAGE_TOOL_NAME, { ...axisExplicitNegatives.get(SEND_MESSAGE_TOOL_NAME), egress: false });
|
|
1416
1419
|
const delegationForRevive = (spec.tools ?? []).find((t) => t.agentListing !== undefined);
|
|
1417
1420
|
const reviveSpawn = delegationForRevive !== undefined && deps.backgroundAgentStore !== undefined && deps.mailboxStore !== undefined
|
|
1418
1421
|
? async (req) => {
|
|
@@ -2813,7 +2816,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
2813
2816
|
list.push({ cls, layer });
|
|
2814
2817
|
foldAskClasses.set(toolCallId, list);
|
|
2815
2818
|
};
|
|
2816
|
-
const sandboxBoundaryCapable = (toolName) => egressTools.has(toolName) || toolName.includes("__") || ownToolNames.has(toolName);
|
|
2819
|
+
const sandboxBoundaryCapable = (toolName) => egressTools.has(toolName) || toolName.includes("__") || ownToolNames.has(toolName) || toolName === SEND_MESSAGE_TOOL_NAME;
|
|
2817
2820
|
const emitSandboxAdmitted = (info) => {
|
|
2818
2821
|
emitTrace(deps.tracer, () => ({
|
|
2819
2822
|
kind: "permission.sandbox_admitted",
|
|
@@ -4220,13 +4223,16 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
4220
4223
|
if (eff !== undefined && !toolEffects.has(t.name))
|
|
4221
4224
|
toolEffects.set(t.name, eff);
|
|
4222
4225
|
}
|
|
4226
|
+
const writeProtectionCheck = createWriteProtectionCheck(deps.writeProtectedPaths);
|
|
4227
|
+
const writeProtectionArmed = writeProtectionCheck !== undefined && tools.some((t) => PATH_CONFINABLE_WRITE_TOOLS.has(t.name));
|
|
4223
4228
|
toolCallGateArmedRef.armed =
|
|
4224
4229
|
effectivePolicy !== undefined ||
|
|
4225
4230
|
hooks?.preToolUse !== undefined ||
|
|
4226
4231
|
egressTools.size > 0 ||
|
|
4227
4232
|
irreversibleTools.size > 0 ||
|
|
4228
4233
|
spec.enablePlanMode === true ||
|
|
4229
|
-
complianceDenies.has("web_fetch")
|
|
4234
|
+
complianceDenies.has("web_fetch") ||
|
|
4235
|
+
writeProtectionArmed;
|
|
4230
4236
|
if (toolCallGateArmedRef.armed) {
|
|
4231
4237
|
harness.on("tool_call", async (e) => {
|
|
4232
4238
|
blockedToolCalls.delete(e.toolCallId);
|
|
@@ -4269,6 +4275,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
4269
4275
|
suspendAsk,
|
|
4270
4276
|
resolveContentAsk,
|
|
4271
4277
|
egress: egressTools.has(e.toolName),
|
|
4278
|
+
peerMessage: e.toolName === SEND_MESSAGE_TOOL_NAME,
|
|
4279
|
+
...(writeProtectionCheck !== undefined ? { writeProtectionCheck } : {}),
|
|
4272
4280
|
irreversibility: irreversibilityTier.get(e.toolName),
|
|
4273
4281
|
reversibilityProbe: reversibilityProbes.get(e.toolName),
|
|
4274
4282
|
abortSignal: abortController.signal,
|
|
@@ -39,8 +39,8 @@ function compilePatterns(patterns) {
|
|
|
39
39
|
}
|
|
40
40
|
return out;
|
|
41
41
|
}
|
|
42
|
-
function matchSensitive(canonicalKey, compiled) {
|
|
43
|
-
return matchSegmentPatterns(canonicalKey, compiled);
|
|
42
|
+
function matchSensitive(canonicalKey, compiled, aliasResolved) {
|
|
43
|
+
return matchSegmentPatterns(canonicalKey, compiled, { aliasResolved });
|
|
44
44
|
}
|
|
45
45
|
export function createSensitivePathPolicy(opts) {
|
|
46
46
|
const compiled = compilePatterns(opts.patterns);
|
|
@@ -64,7 +64,7 @@ export function createSensitivePathPolicy(opts) {
|
|
|
64
64
|
}
|
|
65
65
|
return { action: "allow" };
|
|
66
66
|
}
|
|
67
|
-
const hit = matchSensitive(canon.key, compiled);
|
|
67
|
+
const hit = matchSensitive(canon.key, compiled, canon.aliasResolved === true);
|
|
68
68
|
if (hit) {
|
|
69
69
|
return {
|
|
70
70
|
action: "deny",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type MailboxStore } from "../mailbox-store.js";
|
|
2
2
|
import { type ContractAssertionRunner } from "./contract-harness.js";
|
|
3
3
|
/**
|
|
4
4
|
* design/159 S1 — the cross-backend {@link MailboxStore} contract, extracted VERBATIM from
|
|
@@ -46,3 +46,31 @@ export declare function mailboxAckOwnershipContract(mk: () => MailboxStore, runA
|
|
|
46
46
|
* 放宽」:核心层没有一条为了任何后端而弱化。第三方后端若有租户轴,应当也挂这一层。
|
|
47
47
|
*/
|
|
48
48
|
export declare function mailboxBundledOnlyContract(mk: () => MailboxStore, runAssertion?: ContractAssertionRunner): Promise<void>;
|
|
49
|
+
export interface MailboxTombstonedRecipientContractHooks {
|
|
50
|
+
/** Put `(scope, handle)` into the deployment's PRE-DELETE state — whatever that is for this
|
|
51
|
+
* backend (a cascade marking the session row, a `deleting` column, a tombstone table). Core does
|
|
52
|
+
* not name the mechanism, only what `append` must then do. */
|
|
53
|
+
tombstone: (store: MailboxStore, scope: string, handle: string) => Promise<void>;
|
|
54
|
+
runAssertion?: ContractAssertionRunner;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* T2 可选能力层(pre-delete 分册)—— 只挂**看得见收件人生命周期**的后端:它的 mailbox 行与某个
|
|
58
|
+
* 删除级联要清掉的 session 行同生共死(server 的留存删除级联是首个此形消费方)。三个捆绑后端都
|
|
59
|
+
* 没有这个视角,因此都不挂——**这不是「做不到所以放宽」**:没有预删除态的后端根本没有可拒的对象,
|
|
60
|
+
* 而有这个态却静默接受的后端,拿到的 seq 是一张级联马上就要撕掉的回执(`append` 是 durable-first,
|
|
61
|
+
* 回执即承诺),且没有任何其它面会说出这件事。
|
|
62
|
+
*
|
|
63
|
+
* 契约不规定态怎么置(那是部署自己的删除协议),只钉「拒」这件事的三条轴 —— **三条一起列在这里,
|
|
64
|
+
* 是因为逐条补的过程本身证明了单看一条会假绿**(每加一条,都有一个能过掉前面全部用例的错误实现被
|
|
65
|
+
* 抓出来):
|
|
66
|
+
* 1. **拒因轴**:必须拒,且每条路径上都带同一个码(空箱与非空箱都算路径);
|
|
67
|
+
* 2. **副作用轴**:被拒的 append 对箱零改动 —— 不入箱(幽灵投递)、不清箱(删除窗口内静默丢)、
|
|
68
|
+
* 也不动活租约(动了就撬开 X-1 的单消费者围栏,同一批消息会被二次投递);
|
|
69
|
+
* 3. **收件人边界轴**:态属于一个 `(scope, handle)` 整体,且这个整体是**单射**的 —— 兄弟 handle
|
|
70
|
+
* 不受牵连、跨租户同名不受牵连、拼键歧义不得把两个收件人折成一个。
|
|
71
|
+
* 新增用例请按这三条轴归位;要加第四条轴,先说清它是哪一类假绿。
|
|
72
|
+
*
|
|
73
|
+
* 受众前提与 {@link mailboxBundledOnlyContract} 同线:后端有自己的租户轴(生命周期可见的后端就是
|
|
74
|
+
* 服务端形态的 store),边界轴的两条用例要用两个 scope 构造。
|
|
75
|
+
*/
|
|
76
|
+
export declare function mailboxTombstonedRecipientContract(mk: () => MailboxStore, hooks: MailboxTombstonedRecipientContractHooks): Promise<void>;
|