@sema-agent/core 5.65.0 → 7.0.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 +58 -0
- package/dist/agents/subagent.d.ts +2 -2
- package/dist/agents/subagent.js +11 -0
- package/dist/agents/verify.d.ts +1 -1
- package/dist/brain/anthropic.js +1 -1
- package/dist/brain/errors.d.ts +29 -0
- package/dist/brain/errors.js +20 -0
- package/dist/brain/open-responses.js +2 -2
- package/dist/brain/route-adjudicator.d.ts +8 -1
- package/dist/brain/route-adjudicator.js +1 -0
- package/dist/brain/status-sink.js +12 -1
- package/dist/brain/stream-engine.js +17 -6
- package/dist/core/auto-compaction.d.ts +26 -0
- package/dist/core/auto-compaction.js +7 -2
- package/dist/core/auto-mode-arming.d.ts +138 -0
- package/dist/core/auto-mode-arming.js +181 -0
- package/dist/core/auto-mode-defaults.d.ts +13 -0
- package/dist/core/auto-mode-defaults.js +5 -0
- package/dist/core/auto-mode-prompt.d.ts +14 -3
- package/dist/core/auto-mode-prompt.js +10 -7
- package/dist/core/auto-mode-rebuild.d.ts +75 -0
- package/dist/core/auto-mode-rebuild.js +41 -0
- package/dist/core/auto-mode.d.ts +15 -0
- package/dist/core/auto-mode.js +4 -2
- package/dist/core/checkpoint-store.d.ts +18 -0
- package/dist/core/context-edit.d.ts +47 -5
- package/dist/core/context-guard.d.ts +1 -1
- package/dist/core/file-history-retention.d.ts +106 -0
- package/dist/core/file-history-retention.js +36 -0
- package/dist/core/file-history-store.d.ts +768 -0
- package/dist/core/file-history-store.js +880 -0
- package/dist/core/governance-codes.d.ts +13 -3
- package/dist/core/governance-codes.js +15 -0
- package/dist/core/hooks.d.ts +39 -7
- package/dist/core/hooks.js +38 -21
- package/dist/core/lsp.d.ts +2 -2
- package/dist/core/mcp.d.ts +29 -7
- package/dist/core/memory-engine/consolidation-driver.d.ts +11 -0
- package/dist/core/memory-engine/consolidation-driver.js +71 -4
- package/dist/core/memory-engine/consolidation.d.ts +25 -2
- package/dist/core/memory-engine/consolidation.js +4 -1
- package/dist/core/memory-engine/distiller.d.ts +84 -1
- package/dist/core/memory-engine/distiller.js +68 -0
- package/dist/core/memory-engine/dual-root.js +3 -0
- package/dist/core/memory-engine/engine.d.ts +328 -15
- package/dist/core/memory-engine/engine.js +355 -29
- package/dist/core/memory-engine/file-backend.d.ts +30 -0
- package/dist/core/memory-engine/file-backend.js +14 -13
- package/dist/core/memory-engine/frontmatter.d.ts +22 -1
- package/dist/core/memory-engine/frontmatter.js +3 -0
- package/dist/core/memory-engine/header-hints.d.ts +5 -0
- package/dist/core/memory-engine/index.d.ts +5 -4
- package/dist/core/memory-engine/index.js +5 -4
- package/dist/core/memory-engine/layout.d.ts +88 -2
- package/dist/core/memory-engine/layout.js +112 -3
- package/dist/core/memory-engine/provenance-wording.d.ts +7 -0
- package/dist/core/memory-engine/provenance-wording.js +3 -0
- package/dist/core/memory-engine/tools.d.ts +89 -8
- package/dist/core/memory-engine/tools.js +263 -22
- package/dist/core/memory-engine/types.d.ts +64 -1
- package/dist/core/memory-recall.d.ts +6 -0
- package/dist/core/memory.d.ts +27 -1
- package/dist/core/memory.js +16 -2
- package/dist/core/permission-rule-consent.d.ts +62 -2
- package/dist/core/permission-rule-consent.js +105 -14
- package/dist/core/permission-rule-model.d.ts +118 -16
- package/dist/core/permission-rule-model.js +57 -9
- package/dist/core/permission-rule-session.d.ts +124 -0
- package/dist/core/permission-rule-session.js +121 -0
- package/dist/core/permission-rule-store.d.ts +65 -2
- package/dist/core/permission-rule-store.js +75 -16
- package/dist/core/permission-rule-sync.d.ts +9 -0
- package/dist/core/permission-rule-sync.js +52 -19
- package/dist/core/retention-policy.d.ts +9 -0
- package/dist/core/retention-policy.js +5 -2
- package/dist/core/retention.d.ts +13 -2
- package/dist/core/runner/assemble-result.d.ts +19 -1
- package/dist/core/runner/assemble-result.js +17 -2
- package/dist/core/runner/compaction-call-options.d.ts +93 -0
- package/dist/core/runner/compaction-call-options.js +3 -0
- package/dist/core/runner/memory-capture-optout.d.ts +80 -0
- package/dist/core/runner/memory-capture-optout.js +53 -0
- package/dist/core/runner/prepare-config-doors.d.ts +5 -0
- package/dist/core/runner/prepare-config-doors.js +16 -0
- package/dist/core/runner/prepare-hands-readface.d.ts +110 -5
- package/dist/core/runner/prepare-hands-readface.js +99 -7
- package/dist/core/runner/prepare-memory.d.ts +88 -0
- package/dist/core/runner/prepare-memory.js +305 -24
- package/dist/core/runner/prepare-task.d.ts +141 -1
- package/dist/core/runner/prepare-task.js +478 -81
- package/dist/core/runner/runtask.d.ts +9 -20
- package/dist/core/runner/runtask.js +133 -96
- package/dist/core/runner/session-file-state-replay.d.ts +18 -10
- package/dist/core/runner/session-file-state-replay.js +52 -1
- package/dist/core/runner/tool-disclosure.js +2 -1
- package/dist/core/runner/turn-attachments.d.ts +22 -12
- package/dist/core/session-store.d.ts +1 -1
- package/dist/core/session-store.js +6 -1
- package/dist/core/session.d.ts +34 -1
- package/dist/core/store-contracts/file-history-store-contract.d.ts +3 -0
- package/dist/core/store-contracts/file-history-store-contract.js +720 -0
- package/dist/core/store-contracts/permission-rule-sync-contract.js +15 -1
- package/dist/core/task-registry-shared.js +11 -1
- package/dist/core/tool-errors.js +1 -0
- package/dist/core/tool-policy.d.ts +172 -1
- package/dist/core/tool-policy.js +32 -1
- package/dist/core/tool-result-store.js +2 -1
- package/dist/core/trace.d.ts +31 -2
- package/dist/core/types.d.ts +795 -89
- package/dist/core/types.js +4 -3
- package/dist/core/untrusted-text.d.ts +1 -1
- package/dist/core/untrusted-text.js +8 -0
- package/dist/core/workflow-run-store-contract.js +6 -2
- package/dist/core/workflow-run-store.d.ts +4 -1
- package/dist/engine/compaction/compaction.d.ts +88 -10
- package/dist/engine/compaction/compaction.js +109 -30
- package/dist/engine/execution-env/node-execution-env.d.ts +9 -1
- package/dist/engine/execution-env/node-execution-env.js +28 -0
- package/dist/engine/harness/agent-harness.d.ts +52 -1
- package/dist/engine/harness/agent-harness.js +36 -1
- package/dist/engine/harness/types.d.ts +26 -1
- package/dist/engine/llm/types.d.ts +50 -4
- package/dist/engine/loop/agent-loop.d.ts +5 -1
- package/dist/engine/loop/agent-loop.js +25 -0
- package/dist/engine/loop/types.d.ts +19 -0
- package/dist/engine/lsp/node-lsp-manager.d.ts +1 -1
- package/dist/engine/session/session.js +1 -1
- package/dist/index.d.ts +20 -9
- package/dist/index.js +16 -7
- package/dist/orchestration/run-workflow-tool.d.ts +20 -2
- package/dist/orchestration/run-workflow-tool.js +22 -3
- package/dist/orchestration/workflow-governance.d.ts +59 -1
- package/dist/orchestration/workflow-governance.js +61 -8
- package/dist/orchestration/workflow-meta.d.ts +4 -2
- package/dist/orchestration/workflow-primitives.js +56 -13
- package/dist/orchestration/workflow-types.d.ts +78 -2
- package/dist/orchestration/workflow.d.ts +20 -0
- package/dist/orchestration/workflow.js +163 -14
- package/dist/prompt-assembly/event-registry.js +1 -1
- package/dist/prompts/default.d.ts +7 -7
- package/dist/stores/file/file-history-store.d.ts +368 -0
- package/dist/stores/file/file-history-store.js +1248 -0
- package/dist/stores/file/index.d.ts +22 -13
- package/dist/stores/file/index.js +4 -4
- package/dist/stores/file/permission-rule-store.d.ts +11 -0
- package/dist/stores/file/permission-rule-store.js +23 -9
- package/dist/stores/file/strategy-store.d.ts +3 -3
- package/dist/tools/fs/bash-readonly-classifier.d.ts +87 -3
- package/dist/tools/fs/bash-readonly-classifier.js +106 -4
- package/dist/tools/fs/fs-bash.js +9 -5
- package/dist/tools/fs/fs-shared.d.ts +52 -1
- package/dist/tools/fs/fs-shared.js +14 -0
- package/dist/tools/fs/fs-write.d.ts +5 -5
- package/dist/tools/fs/fs-write.js +71 -14
- package/dist/tools/fs/index.d.ts +6 -1
- package/dist/tools/fs/index.js +1 -1
- package/dist/tools/web.js +2 -1
- package/package.json +5 -1
- package/test/export-surface.snapshot.json +169 -23
- package/dist/core/file-snapshot-store.d.ts +0 -165
- package/dist/core/file-snapshot-store.js +0 -259
- package/dist/core/store-contracts/file-snapshot-store-contract.d.ts +0 -13
- package/dist/core/store-contracts/file-snapshot-store-contract.js +0 -134
- package/dist/stores/file/file-snapshot-store.d.ts +0 -58
- package/dist/stores/file/file-snapshot-store.js +0 -353
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/382 §4.3 — the SESSION-RULE OVERLAY: where a `{kind:"session"}` scoped allow rule lives.
|
|
3
|
+
*
|
|
4
|
+
* A session authorization's home is the session's own state — never the persisted store. The rows are
|
|
5
|
+
* ordinary {@link PersistedAllowRule}s (scope `session`), so every read-side consumer — the gate's
|
|
6
|
+
* conjunction arm, the coverage table, the consent prepare — takes them through the ONE eligibility
|
|
7
|
+
* predicate with zero second judges: the merge point splices overlay rows IN FRONT of the persisted
|
|
8
|
+
* `list()` (the §4.1 narrowest-first reporting order: session > project > global), and everything
|
|
9
|
+
* downstream is unchanged.
|
|
10
|
+
*
|
|
11
|
+
* ## The contract (design/382 §8-Q6, settled here)
|
|
12
|
+
*
|
|
13
|
+
* TWO verbs, and deliberately no third:
|
|
14
|
+
* · `read(sessionId)` — the live rows of ONE session, session-scoped, that session's only.
|
|
15
|
+
* · `apply(sessionId, add)` — land one redeemed authorization. Idempotent BY DOT (the redemption
|
|
16
|
+
* replay contract: `alreadyRedeemed` marks the dot as RECORDED, not the overlay as LANDED, so a
|
|
17
|
+
* replay must RE-APPLY, and this verb is what makes re-applying safe); refuses `session_ended`
|
|
18
|
+
* once the session's lifetime epoch is sealed — a late in-flight write after termination must not
|
|
19
|
+
* revive an authorization for a session that no longer exists.
|
|
20
|
+
*
|
|
21
|
+
* There is deliberately NO delete verb: a session row is not individually deletable — it dies with
|
|
22
|
+
* its session (design/382 §10, ruled). Termination is the HOST's act on its own implementation (see
|
|
23
|
+
* {@link InMemorySessionRuleOverlay.endSession}), not an engine verb.
|
|
24
|
+
*
|
|
25
|
+
* ## Lifecycle and durability
|
|
26
|
+
*
|
|
27
|
+
* The overlay lives WITH the session: a host that parks/checkpoints a session serializes its rows
|
|
28
|
+
* beside it and rehydrates them on resume (`snapshotSession`/`restoreSession` on the reference
|
|
29
|
+
* implementation) — that is how a session authorization survives a park without ever touching the
|
|
30
|
+
* persisted store, and why it still ends when the session ends. `sessionId` uniqueness is the host's
|
|
31
|
+
* duty (the `principal` posture): reusing an id declares "the same session".
|
|
32
|
+
*
|
|
33
|
+
* ## What this is NOT
|
|
34
|
+
*
|
|
35
|
+
* Not a store backend: no dots are minted here (the redemption leg mints the audit dot exactly as the
|
|
36
|
+
* store leg does), no tombstones exist here, nothing here syncs. Every durable entrance — write
|
|
37
|
+
* deltas, sync in both directions, at-rest bytes — refuses or drops a session row
|
|
38
|
+
* (`permission-rule-store.ts`, the durable two-member face); this module is the OTHER side of that
|
|
39
|
+
* wall, and the wall is the design.
|
|
40
|
+
*/
|
|
41
|
+
import type { PersistedAllowRule, RuleAdd, PersistedRuleMatch, PersistedRuleTool } from "./permission-rule-model.js";
|
|
42
|
+
/** One redeemed authorization landing in a session's overlay: the rule's canonical shape plus the
|
|
43
|
+
* redemption's own add (dot + provenance) — the same fields a `redemption-add` delta carries, minus
|
|
44
|
+
* the scope (the verb's `sessionId` IS the scope, constructed here so a caller cannot land a row
|
|
45
|
+
* whose scope names a different session than the one it addressed). */
|
|
46
|
+
export interface SessionRuleOverlayAdd {
|
|
47
|
+
rule: string;
|
|
48
|
+
tool: PersistedRuleTool;
|
|
49
|
+
match: PersistedRuleMatch;
|
|
50
|
+
command: string;
|
|
51
|
+
add: RuleAdd;
|
|
52
|
+
}
|
|
53
|
+
/** What one overlay apply answered. `session_ended` is the epoch fence speaking: the write arrived at
|
|
54
|
+
* (or after) termination and landed nothing — the redemption reports the member `refused`. */
|
|
55
|
+
export type SessionRuleOverlayApplyResult = {
|
|
56
|
+
landed: true;
|
|
57
|
+
} | {
|
|
58
|
+
refused: "session_ended";
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The host-provided seam the consent lane and the gate's rule lane consume
|
|
62
|
+
* (`RuleConsentDeps.sessionRules`, `RunnerDeps.sessionPermissionRules`). See the module doc for the
|
|
63
|
+
* contract; {@link InMemorySessionRuleOverlay} is the reference implementation and the semantics.
|
|
64
|
+
*/
|
|
65
|
+
export interface SessionRuleOverlay {
|
|
66
|
+
/** The live session-scoped rows of ONE session. An ended (or never-written) session reads as `[]`. */
|
|
67
|
+
read(sessionId: string): Promise<readonly PersistedAllowRule[]>;
|
|
68
|
+
/** Land one redeemed authorization — idempotent by dot, refused once the session ended. */
|
|
69
|
+
apply(sessionId: string, add: SessionRuleOverlayAdd): Promise<SessionRuleOverlayApplyResult>;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The reference overlay — in-memory, per-process, the semantics every host implementation must keep:
|
|
73
|
+
*
|
|
74
|
+
* · APPLY is idempotent by dot identity (a replayed redemption re-submits its recorded dot and
|
|
75
|
+
* changes nothing — the store's `foldDelta` discipline, verbatim);
|
|
76
|
+
* · TERMINATION seals FIRST, clears SECOND ({@link endSession}): the sealed-set membership is the
|
|
77
|
+
* session's lifetime epoch, flipped atomically before any state is dropped, so an in-flight write
|
|
78
|
+
* that lost the race lands on the seal and answers `session_ended` — never on a half-cleared
|
|
79
|
+
* session, and never as a row that outlives its session (design/382 §4.3, the r2-⑥ fence);
|
|
80
|
+
* · a sealed session never reopens — `apply` and `restoreSession` both refuse it, so "session ended"
|
|
81
|
+
* is a one-way fact exactly like a quarantined dot's;
|
|
82
|
+
* · SNAPSHOT/RESTORE ({@link snapshotSession}/{@link restoreSession}) are the park/resume carriage:
|
|
83
|
+
* the host serializes a session's rows into its checkpoint and rehydrates them on resume. Restore
|
|
84
|
+
* is fail-closed on shape — every row must be a session row of THE session being restored (a
|
|
85
|
+
* checkpoint is host-owned bytes, and a global row smuggled through this door would ride the
|
|
86
|
+
* merge point straight into adjudication).
|
|
87
|
+
*/
|
|
88
|
+
export declare class InMemorySessionRuleOverlay implements SessionRuleOverlay {
|
|
89
|
+
private readonly rows;
|
|
90
|
+
private readonly sealed;
|
|
91
|
+
/** How many times one session's row set has CHANGED. Reading a caller-owned value runs caller code,
|
|
92
|
+
* so a verb that reads its argument and then replaces a session's rows wholesale must be able to
|
|
93
|
+
* tell that something landed in between — otherwise it overwrites a grant that was already answered
|
|
94
|
+
* `landed:true`, and a consent nobody withdrew stops existing. Bumped by every landing, restore and
|
|
95
|
+
* termination; read across `restoreSession`'s snapshot. */
|
|
96
|
+
private readonly epochs;
|
|
97
|
+
private bumpEpoch;
|
|
98
|
+
read(sessionId: string): Promise<readonly PersistedAllowRule[]>;
|
|
99
|
+
apply(sessionId: string, add: SessionRuleOverlayAdd): Promise<SessionRuleOverlayApplyResult>;
|
|
100
|
+
/**
|
|
101
|
+
* The termination transfer: SEAL the lifetime epoch first (one atomic membership flip — after this
|
|
102
|
+
* line every in-flight `apply` holding this id answers `session_ended`), THEN drop the rows. The
|
|
103
|
+
* order is the whole fence: clear-then-seal would leave a window in which a late write re-grows a
|
|
104
|
+
* rule set for a session that is already gone. Idempotent; a never-seen id seals to the same place.
|
|
105
|
+
*/
|
|
106
|
+
endSession(sessionId: string): void;
|
|
107
|
+
/** The park half of the checkpoint carriage: this session's rows, serializable as-is. */
|
|
108
|
+
snapshotSession(sessionId: string): Promise<readonly PersistedAllowRule[]>;
|
|
109
|
+
/**
|
|
110
|
+
* The resume half. Fail-closed on every axis: a sealed session never rehydrates (termination is
|
|
111
|
+
* one-way), and every row must be a well-formed SESSION row of exactly this session — anything else
|
|
112
|
+
* in the checkpoint bytes is refused whole, loudly, because the merge point trusts this overlay to
|
|
113
|
+
* serve only what its contract says it holds.
|
|
114
|
+
*
|
|
115
|
+
* The rows also pass the SAME `normalizePersistedRule` re-projection the sync face runs on store
|
|
116
|
+
* rows (rule text parses, canonical spelling, stored tool/match/command agree with the text): this
|
|
117
|
+
* door takes host-owned checkpoint bytes with no redemption validator in front of them, and a row
|
|
118
|
+
* the engine could not re-project would otherwise sit one merge away from adjudication. Rows this
|
|
119
|
+
* overlay landed itself always pass — {@link InMemorySessionRuleOverlay.apply} runs the same screen,
|
|
120
|
+
* so the two doors agree on what a session row is and a snapshot of this overlay always restores —
|
|
121
|
+
* which is what makes a refusal here foreign damage, refused whole.
|
|
122
|
+
*/
|
|
123
|
+
restoreSession(sessionId: string, rows: readonly PersistedAllowRule[]): Promise<void>;
|
|
124
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { isValidConsentScope, normalizePersistedRule } from "./permission-rule-store.js";
|
|
2
|
+
function isValidOverlayAdd(add) {
|
|
3
|
+
const a = add;
|
|
4
|
+
if (typeof a?.dot?.actor !== "string" || a.dot.actor === "" || typeof a.dot.counter !== "number" || !Number.isFinite(a.dot.counter))
|
|
5
|
+
return false;
|
|
6
|
+
if (a.origin !== "user" && a.origin !== "imported-cc" && a.origin !== "starter")
|
|
7
|
+
return false;
|
|
8
|
+
return typeof a.createdAt === "string";
|
|
9
|
+
}
|
|
10
|
+
export class InMemorySessionRuleOverlay {
|
|
11
|
+
rows = new Map();
|
|
12
|
+
sealed = new Set();
|
|
13
|
+
epochs = new Map();
|
|
14
|
+
bumpEpoch(sessionId) {
|
|
15
|
+
this.epochs.set(sessionId, (this.epochs.get(sessionId) ?? 0) + 1);
|
|
16
|
+
}
|
|
17
|
+
async read(sessionId) {
|
|
18
|
+
return structuredClone(this.rows.get(sessionId) ?? []);
|
|
19
|
+
}
|
|
20
|
+
async apply(sessionId, add) {
|
|
21
|
+
if (this.sealed.has(sessionId))
|
|
22
|
+
return { refused: "session_ended" };
|
|
23
|
+
add = structuredClone(add);
|
|
24
|
+
if (this.sealed.has(sessionId))
|
|
25
|
+
return { refused: "session_ended" };
|
|
26
|
+
if (!isValidOverlayAdd(add?.add)) {
|
|
27
|
+
throw new Error("SessionRuleOverlay.apply was handed a malformed add (dot/origin/createdAt) — evidence this shape cannot vouch for must not install");
|
|
28
|
+
}
|
|
29
|
+
const scope = { kind: "session", sessionId };
|
|
30
|
+
if (!isValidConsentScope(scope)) {
|
|
31
|
+
throw new Error("SessionRuleOverlay.apply was addressed with an unusable session id — a session nobody can name is not a scope a grant can live under");
|
|
32
|
+
}
|
|
33
|
+
const projected = { rule: add.rule, tool: add.tool, match: add.match, command: add.command, scope, adds: [add.add] };
|
|
34
|
+
if ("reject" in normalizePersistedRule(projected)) {
|
|
35
|
+
throw new Error("SessionRuleOverlay.apply was handed a row whose text/metadata do not survive the canonical re-projection — a row the engine cannot re-project can never adjudicate, and would refuse its own restore");
|
|
36
|
+
}
|
|
37
|
+
const rows = this.rows.get(sessionId) ?? [];
|
|
38
|
+
if (!this.rows.has(sessionId))
|
|
39
|
+
this.rows.set(sessionId, rows);
|
|
40
|
+
const sameDotAs = (a) => a.dot.actor === add.add.dot.actor && a.dot.counter === add.add.dot.counter;
|
|
41
|
+
for (const [heldSession, heldRows] of this.rows) {
|
|
42
|
+
const holder = heldRows.find((r) => r.adds.some(sameDotAs));
|
|
43
|
+
if (holder !== undefined && (heldSession !== sessionId || holder.rule !== add.rule)) {
|
|
44
|
+
throw new Error(`SessionRuleOverlay.apply: dot ${add.add.dot.actor}#${add.add.dot.counter} already vouches for another grant — a dot identifies one add of one (rule, session), ever`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const existing = rows.find((r) => r.rule === add.rule);
|
|
48
|
+
if (existing === undefined) {
|
|
49
|
+
rows.push({ rule: add.rule, tool: add.tool, match: add.match, command: add.command, scope, adds: [structuredClone(add.add)] });
|
|
50
|
+
this.bumpEpoch(sessionId);
|
|
51
|
+
return { landed: true };
|
|
52
|
+
}
|
|
53
|
+
if (!existing.adds.some((a) => a.dot.actor === add.add.dot.actor && a.dot.counter === add.add.dot.counter)) {
|
|
54
|
+
existing.adds.push(structuredClone(add.add));
|
|
55
|
+
this.bumpEpoch(sessionId);
|
|
56
|
+
}
|
|
57
|
+
return { landed: true };
|
|
58
|
+
}
|
|
59
|
+
endSession(sessionId) {
|
|
60
|
+
this.sealed.add(sessionId);
|
|
61
|
+
this.rows.delete(sessionId);
|
|
62
|
+
this.bumpEpoch(sessionId);
|
|
63
|
+
}
|
|
64
|
+
async snapshotSession(sessionId) {
|
|
65
|
+
return this.read(sessionId);
|
|
66
|
+
}
|
|
67
|
+
async restoreSession(sessionId, rows) {
|
|
68
|
+
if (this.sealed.has(sessionId)) {
|
|
69
|
+
throw new Error(`session "${sessionId}" has ended — a sealed session's authorizations cannot be restored (termination is one-way)`);
|
|
70
|
+
}
|
|
71
|
+
const epochBeforeRead = this.epochs.get(sessionId) ?? 0;
|
|
72
|
+
rows = structuredClone(rows);
|
|
73
|
+
if (this.sealed.has(sessionId)) {
|
|
74
|
+
throw new Error(`session "${sessionId}" has ended — a sealed session's authorizations cannot be restored (termination is one-way)`);
|
|
75
|
+
}
|
|
76
|
+
if ((this.epochs.get(sessionId) ?? 0) !== epochBeforeRead) {
|
|
77
|
+
throw new Error(`restoreSession("${sessionId}") raced a landing on the same session — refusing the whole restore (this door replaces the session's rows, and a grant that already answered landed must not be erased by a resume)`);
|
|
78
|
+
}
|
|
79
|
+
for (const r of rows) {
|
|
80
|
+
const scope = r?.scope;
|
|
81
|
+
if (scope?.kind !== "session" || scope.sessionId !== sessionId || !isValidConsentScope(scope)) {
|
|
82
|
+
throw new Error(`restoreSession("${sessionId}") was handed a row that is not a session row of that session — refusing the whole restore (a foreign-scope row through this door would reach adjudication)`);
|
|
83
|
+
}
|
|
84
|
+
if ("reject" in normalizePersistedRule(r)) {
|
|
85
|
+
throw new Error(`restoreSession("${sessionId}") was handed a row whose text/metadata do not survive the canonical re-projection — refusing the whole restore (a row the engine cannot re-project must not reach adjudication)`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const ownDotRule = new Map();
|
|
89
|
+
for (const held of this.rows.get(sessionId) ?? []) {
|
|
90
|
+
for (const h of held.adds)
|
|
91
|
+
ownDotRule.set(`${h.dot.actor}#${h.dot.counter}`, held.rule);
|
|
92
|
+
}
|
|
93
|
+
const seenDots = new Map();
|
|
94
|
+
for (const r of rows) {
|
|
95
|
+
if (!Array.isArray(r.adds) || r.adds.length === 0 || !r.adds.every(isValidOverlayAdd)) {
|
|
96
|
+
throw new Error(`restoreSession("${sessionId}") was handed a row with malformed add evidence — refusing the whole restore (an add whose dot/origin cannot be read is not a consent anyone can audit)`);
|
|
97
|
+
}
|
|
98
|
+
for (const a of r.adds) {
|
|
99
|
+
const key = `${a.dot.actor}#${a.dot.counter}`;
|
|
100
|
+
const holder = seenDots.get(key);
|
|
101
|
+
if (holder !== undefined && holder !== r.rule) {
|
|
102
|
+
throw new Error(`restoreSession("${sessionId}"): dot ${key} vouches for two different rules — refusing the whole restore`);
|
|
103
|
+
}
|
|
104
|
+
seenDots.set(key, r.rule);
|
|
105
|
+
const ownHolder = ownDotRule.get(key);
|
|
106
|
+
if (ownHolder !== undefined && ownHolder !== r.rule) {
|
|
107
|
+
throw new Error(`restoreSession("${sessionId}"): dot ${key} is live in this session under a different rule — a restore may replay a grant, never reassign its dot; refusing the whole restore`);
|
|
108
|
+
}
|
|
109
|
+
for (const [heldSession, heldRows] of this.rows) {
|
|
110
|
+
if (heldSession === sessionId)
|
|
111
|
+
continue;
|
|
112
|
+
if (heldRows.some((held) => held.adds.some((h) => h.dot.actor === a.dot.actor && h.dot.counter === a.dot.counter))) {
|
|
113
|
+
throw new Error(`restoreSession("${sessionId}"): dot ${key} already vouches for another live session's grant — refusing the whole restore`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
this.rows.set(sessionId, rows);
|
|
119
|
+
this.bumpEpoch(sessionId);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -215,7 +215,8 @@ export interface WritablePermissionRuleStore extends PermissionRuleStore {
|
|
|
215
215
|
}
|
|
216
216
|
/** The writer of a store, or `undefined` when the backend is read-only from the engine's side. */
|
|
217
217
|
export declare function writerOf(store: PermissionRuleStore): PermissionRuleWriter | undefined;
|
|
218
|
-
/** Do two scopes name the same place?
|
|
218
|
+
/** Do two scopes name the same place? Three members (design/382 §4.1): global, project-by-root,
|
|
219
|
+
* session-by-sessionId. */
|
|
219
220
|
export declare function sameScope(a: RuleScope, b: RuleScope): boolean;
|
|
220
221
|
/**
|
|
221
222
|
* Apply tombstones to a raw persisted set: an add survives iff its dot appears in no tombstone for the
|
|
@@ -238,6 +239,33 @@ export declare function addDotsOf(rules: readonly PersistedAllowRule[]): RuleDot
|
|
|
238
239
|
* Every backend calls this before appending a tombstone.
|
|
239
240
|
*/
|
|
240
241
|
export declare function assertDeleteDeltaCarriesNoAdd(delta: RuleDeleteDelta): void;
|
|
242
|
+
/**
|
|
243
|
+
* design/382 §4.3 — the DURABLE-scope gate of the two direct write arms, shared by every backend
|
|
244
|
+
* (the `assertDeleteDeltaCarriesNoAdd` doctrine: the check is executed, not merely intended, because a
|
|
245
|
+
* union does not constrain a JavaScript caller's payload).
|
|
246
|
+
*
|
|
247
|
+
* A `redemption-add` whose scope — or a `tighten-delete` whose tombstone's scope — is a session scope
|
|
248
|
+
* refuses LOUDLY with the typed code `unsupported.session_scope_store`: a session authorization's home
|
|
249
|
+
* is the session's own overlay, and a session TOMBSTONE is equally unspellable here — session rows are
|
|
250
|
+
* not individually deletable (they die with their session, design/382 §10), so a tombstone naming one
|
|
251
|
+
* is either a foreign writer's fabrication or a caller wiring fault, and both deserve the loud arm.
|
|
252
|
+
* The third arm (`sync-join`) is NOT gated here: its session rows are per-row DROPPED AND DISCLOSED by
|
|
253
|
+
* {@link screenRuleSyncState} (a peer's damage must not veto a whole round the way a local caller's
|
|
254
|
+
* bug must), and a session-scoped QUARANTINE instruction throws inside {@link applySyncJoin} itself.
|
|
255
|
+
*
|
|
256
|
+
* The session member is the LOUD, typed arm; the rest of the face is executed too. A scope that is
|
|
257
|
+
* neither member in well-formed shape — `{kind:"project", root:""}` above all, which `pathWithinRoot`
|
|
258
|
+
* turns into base `/` so the row crosses every project boundary — is a caller wiring fault, refused
|
|
259
|
+
* with the untyped loud arm the sibling {@link assertDeleteDeltaCarriesNoAdd} uses (no closed-set code
|
|
260
|
+
* names it: it is nobody's supported request). The sync arm already refuses the same shape inbound
|
|
261
|
+
* ({@link applySyncJoin} runs {@link isValidDurableScope} on every joined row), so this closes the
|
|
262
|
+
* matching direct-write door rather than inventing a new rule.
|
|
263
|
+
*
|
|
264
|
+
* Exported with the backend contract for the same OPEN-SET duty {@link isValidDurableScope} names:
|
|
265
|
+
* any lane — host-side import doors included — that writes rows into a persisted store carries the
|
|
266
|
+
* durable two-member face.
|
|
267
|
+
*/
|
|
268
|
+
export declare function assertWriteDeltaScopeDurable(delta: RuleAddDelta | RuleDeleteDelta): void;
|
|
241
269
|
/**
|
|
242
270
|
* The state one sync round exchanges: raw adds (tombstoned dots NOT pre-filtered) plus tombstones.
|
|
243
271
|
* The live view is always DERIVED (`applyTombstones`); the join below never deletes anything.
|
|
@@ -291,6 +319,27 @@ export interface RuleSyncLandingReport {
|
|
|
291
319
|
* a dropped tombstone may have covered them — they re-arrive once the peer's state is clean). */
|
|
292
320
|
withheldAdds: number;
|
|
293
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* design/382 §4.3 — the CONSENT face's scope validity: the union's full three members, their shapes
|
|
324
|
+
* executed (a project scope with an empty root would admit every cwd, wider than global, through
|
|
325
|
+
* `pathWithinRoot`'s base construction; an empty sessionId would be a session nobody can name).
|
|
326
|
+
* Read by the consent protocol's entries (prepare's explicit-scope gate, the record integrity walk),
|
|
327
|
+
* and exported for the server's consent-projection twin. NEVER by a durable entrance — that face is
|
|
328
|
+
* {@link isValidDurableScope}, and keeping the two as separate named tables is the design's point:
|
|
329
|
+
* a session row leaking toward the store must meet an explicit refusal at every door, not a shared
|
|
330
|
+
* table someone widened for the other face's sake.
|
|
331
|
+
*/
|
|
332
|
+
export declare function isValidConsentScope(scope: RuleScope): boolean;
|
|
333
|
+
/**
|
|
334
|
+
* design/382 §4.3 — the DURABLE face's scope validity: the two members a persisted row may carry,
|
|
335
|
+
* `{global, project}`. A session scope is a WELL-FORMED consent scope that is structurally not a
|
|
336
|
+
* durable one — session authorizations live in the session's own overlay and die with it; letting one
|
|
337
|
+
* into the store (or the sync wire, or the at-rest bytes) would give it exactly the afterlife the
|
|
338
|
+
* dimension is defined not to have. Exported for the server's rule-sync validator (same face, same
|
|
339
|
+
* two members) and for any host-side lane that writes rows into a persisted store — the OPEN-SET
|
|
340
|
+
* duty (design/382 §10): every such entrance, present or future, carries this face.
|
|
341
|
+
*/
|
|
342
|
+
export declare function isValidDurableScope(scope: RuleScope): boolean;
|
|
294
343
|
/** Is `dot` at or below the frontier? Absent actor ⇒ not below (a frontier never covers what it never saw). */
|
|
295
344
|
export declare function dotAtOrBelowFrontier(dot: RuleDot, frontier: RuleSyncFrontier): boolean;
|
|
296
345
|
/**
|
|
@@ -314,7 +363,21 @@ export declare function normalizePersistedRule(r: PersistedAllowRule): {
|
|
|
314
363
|
} | {
|
|
315
364
|
reject: Extract<RuleSyncDropReason, "invalid_rule_text" | "metadata_mismatch">;
|
|
316
365
|
};
|
|
317
|
-
/** The normalization half exposed for the DISCLOSURE layer: which records a join would refuse, and why.
|
|
366
|
+
/** The normalization half exposed for the DISCLOSURE layer: which records a join would refuse, and why.
|
|
367
|
+
*
|
|
368
|
+
* design/382 §4.3 — this screen carries the SESSION member of the durable face on every JOIN leg
|
|
369
|
+
* (inbound state, local state, both sides of the pure join): a rule or tombstone row carrying a
|
|
370
|
+
* session scope is dropped and reported (`session_scope_not_durable`), never landed and never allowed
|
|
371
|
+
* to veto the round the way a malformed tombstone does. Dropping a session TOMBSTONE cannot widen:
|
|
372
|
+
* tombstones remove adds only under the SAME (rule, scope), and every session-scope add is dropped by
|
|
373
|
+
* this same screen — the #176 "its cover is not load-bearing" argument, one scope over.
|
|
374
|
+
*
|
|
375
|
+
* The face's OTHER member — a durable scope that is not well-formed, `{kind:"project", root:""}`
|
|
376
|
+
* above all — is executed on the INBOUND legs by {@link applySyncJoin} (its tombstone pre-loop and its
|
|
377
|
+
* rule arm both run {@link isValidDurableScope}) and at the direct write arms by
|
|
378
|
+
* {@link assertWriteDeltaScopeDurable}, not here. Stated so the boundary is readable: a LOCAL row of
|
|
379
|
+
* that shape — reachable only by editing the at-rest bytes, or from a store written before the write
|
|
380
|
+
* arm executed that axis — passes this screen and stays live. */
|
|
318
381
|
export declare function screenRuleSyncState(state: RuleSyncState): {
|
|
319
382
|
state: RuleSyncState;
|
|
320
383
|
rejected: RuleSyncDrop[];
|
|
@@ -10,7 +10,11 @@ export function writerOf(store) {
|
|
|
10
10
|
return w !== undefined && typeof w.apply === "function" && typeof w.nextDot === "function" ? w : undefined;
|
|
11
11
|
}
|
|
12
12
|
export function sameScope(a, b) {
|
|
13
|
-
|
|
13
|
+
if (a.kind === "global")
|
|
14
|
+
return b.kind === "global";
|
|
15
|
+
if (a.kind === "session")
|
|
16
|
+
return b.kind === "session" && a.sessionId === b.sessionId;
|
|
17
|
+
return b.kind === "project" && a.root === b.root;
|
|
14
18
|
}
|
|
15
19
|
function sameDot(a, b) {
|
|
16
20
|
return a.actor === b.actor && a.counter === b.counter;
|
|
@@ -47,7 +51,29 @@ export function assertDeleteDeltaCarriesNoAdd(delta) {
|
|
|
47
51
|
throw new Error("a tighten-delete must carry a tombstone naming at least one observed add dot");
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
|
-
function
|
|
54
|
+
export function assertWriteDeltaScopeDurable(delta) {
|
|
55
|
+
const scope = delta.kind === "redemption-add" ? delta.scope : delta.tombstone?.scope;
|
|
56
|
+
if (scope?.kind === "session") {
|
|
57
|
+
const e = new Error(delta.kind === "redemption-add"
|
|
58
|
+
? "a session-scope rule cannot enter the persisted store — its home is the session's own overlay (design/382 §4.3); refusing the redemption-add"
|
|
59
|
+
: "a session-scope tombstone cannot enter the persisted store — session rows are not individually deletable and die with their session (design/382 §4.3/§10); refusing the tighten-delete");
|
|
60
|
+
e.code = "unsupported.session_scope_store";
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
if (!isValidDurableScope(scope)) {
|
|
64
|
+
throw new Error(delta.kind === "redemption-add"
|
|
65
|
+
? "a redemption-add whose scope is not a well-formed durable scope cannot enter the persisted store (design/382 §4.3: the face is {global, project-with-a-root}) — refusing the write"
|
|
66
|
+
:
|
|
67
|
+
"a tighten-delete must carry a tombstone whose scope is a well-formed durable scope (design/382 §4.3: the face is {global, project-with-a-root}) — refusing the write");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function isValidConsentScope(scope) {
|
|
71
|
+
const sc = scope;
|
|
72
|
+
return (sc?.kind === "global" ||
|
|
73
|
+
(sc?.kind === "project" && typeof sc.root === "string" && sc.root !== "") ||
|
|
74
|
+
(sc?.kind === "session" && typeof sc.sessionId === "string" && sc.sessionId !== ""));
|
|
75
|
+
}
|
|
76
|
+
export function isValidDurableScope(scope) {
|
|
51
77
|
const sc = scope;
|
|
52
78
|
return sc?.kind === "global" || (sc?.kind === "project" && typeof sc.root === "string" && sc.root !== "");
|
|
53
79
|
}
|
|
@@ -56,7 +82,7 @@ function isValidDot(dot) {
|
|
|
56
82
|
return typeof d?.actor === "string" && d.actor !== "" && typeof d.counter === "number" && Number.isFinite(d.counter);
|
|
57
83
|
}
|
|
58
84
|
function scopeKey(scope) {
|
|
59
|
-
return JSON.stringify(scope.kind === "global" ? ["g"] : ["p", scope.root]);
|
|
85
|
+
return JSON.stringify(scope.kind === "global" ? ["g"] : scope.kind === "session" ? ["s", scope.sessionId] : ["p", scope.root]);
|
|
60
86
|
}
|
|
61
87
|
function dotKey(dot) {
|
|
62
88
|
return JSON.stringify([dot.actor, dot.counter]);
|
|
@@ -114,6 +140,11 @@ export function screenRuleSyncState(state) {
|
|
|
114
140
|
for (const r of state.rules) {
|
|
115
141
|
if (!Array.isArray(r.adds) || r.adds.length === 0)
|
|
116
142
|
continue;
|
|
143
|
+
if (r.scope?.kind === "session") {
|
|
144
|
+
for (const a of r.adds)
|
|
145
|
+
rejected.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "session_scope_not_durable" });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
117
148
|
const n = normalizePersistedRule(r);
|
|
118
149
|
if ("reject" in n) {
|
|
119
150
|
for (const a of r.adds)
|
|
@@ -122,7 +153,15 @@ export function screenRuleSyncState(state) {
|
|
|
122
153
|
}
|
|
123
154
|
rules.push(r);
|
|
124
155
|
}
|
|
125
|
-
|
|
156
|
+
const tombstones = [];
|
|
157
|
+
for (const t of state.tombstones) {
|
|
158
|
+
if (t?.scope?.kind === "session") {
|
|
159
|
+
rejected.push({ rule: t.rule, scope: t.scope, dot: t.deletedBy, reason: "session_scope_not_durable" });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
tombstones.push(t);
|
|
163
|
+
}
|
|
164
|
+
return { state: { rules, tombstones }, rejected };
|
|
126
165
|
}
|
|
127
166
|
export function joinRuleStates(a, b) {
|
|
128
167
|
const sa = screenRuleSyncState(a).state;
|
|
@@ -183,10 +222,12 @@ export function applySyncJoin(cur, delta, nowMs) {
|
|
|
183
222
|
throw new Error("a sync-join delta must carry an inbound state with rules and tombstones arrays");
|
|
184
223
|
}
|
|
185
224
|
for (const t of delta.inbound.tombstones) {
|
|
225
|
+
if (t?.scope?.kind === "session")
|
|
226
|
+
continue;
|
|
186
227
|
if (!Array.isArray(t?.removedDots) || t.removedDots.length === 0) {
|
|
187
228
|
throw new Error("an inbound tombstone must name at least one observed add dot — refusing the whole sync-join (the client's partitioning must withhold the round's adds alongside a malformed tombstone)");
|
|
188
229
|
}
|
|
189
|
-
if (!
|
|
230
|
+
if (!isValidDurableScope(t.scope) || !isValidDot(t.deletedBy) || !t.removedDots.every(isValidDot)) {
|
|
190
231
|
throw new Error("an inbound tombstone carries a malformed scope or dot — refusing the whole sync-join (a tombstone that cannot identity-match what it deletes would land its covered adds live)");
|
|
191
232
|
}
|
|
192
233
|
const canonicalTomb = parseAllowRuleText(t.rule);
|
|
@@ -229,7 +270,7 @@ export function applySyncJoin(cur, delta, nowMs) {
|
|
|
229
270
|
withheldAdds += r.adds.length;
|
|
230
271
|
continue;
|
|
231
272
|
}
|
|
232
|
-
if (!
|
|
273
|
+
if (!isValidDurableScope(r.scope)) {
|
|
233
274
|
for (const a of r.adds)
|
|
234
275
|
droppedInbound.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "metadata_mismatch" });
|
|
235
276
|
continue;
|
|
@@ -288,6 +329,11 @@ export function applySyncJoin(cur, delta, nowMs) {
|
|
|
288
329
|
}
|
|
289
330
|
}
|
|
290
331
|
for (const q of delta.quarantine ?? []) {
|
|
332
|
+
if (q.scope?.kind === "session") {
|
|
333
|
+
const e = new Error("a quarantine instruction names a session scope — the persisted store never holds a session row (design/382 §4.3); refusing the instruction");
|
|
334
|
+
e.code = "unsupported.session_scope_store";
|
|
335
|
+
throw e;
|
|
336
|
+
}
|
|
291
337
|
for (const dot of q.dots) {
|
|
292
338
|
if (joined.tombstones.some((t) => sameDot(t.deletedBy, dot))) {
|
|
293
339
|
throw new Error("a quarantine instruction may not touch a tombstone — it moves adds only");
|
|
@@ -312,7 +358,7 @@ export function applySyncJoin(cur, delta, nowMs) {
|
|
|
312
358
|
quarantined,
|
|
313
359
|
observedVector: delta.observedVector !== undefined ? joinFrontiers(cur.observedVector ?? {}, delta.observedVector) : cur.observedVector,
|
|
314
360
|
},
|
|
315
|
-
report: { droppedInbound, quarantined: quarantinedNow, collectedTombstones, withheldAdds },
|
|
361
|
+
report: structuredClone({ droppedInbound, quarantined: quarantinedNow, collectedTombstones, withheldAdds }),
|
|
316
362
|
};
|
|
317
363
|
}
|
|
318
364
|
export function assertRedemptionNotQuarantined(quarantined, delta) {
|
|
@@ -327,11 +373,21 @@ export async function ruleStoreChecksum(payload) {
|
|
|
327
373
|
}
|
|
328
374
|
const REMOVE_MAX_ATTEMPTS = 8;
|
|
329
375
|
export async function removePersistedRule(opts) {
|
|
330
|
-
const
|
|
331
|
-
|
|
376
|
+
const rule = opts.rule;
|
|
377
|
+
const scope = structuredClone(opts.scope);
|
|
378
|
+
const target = opts.principal;
|
|
379
|
+
const owner = typeof target === "string" ? { kind: "principal", principal: target } : structuredClone(target);
|
|
380
|
+
const provider = opts.provider;
|
|
381
|
+
if (scope?.kind === "session") {
|
|
382
|
+
return {
|
|
383
|
+
status: "failed",
|
|
384
|
+
error: "unsupported.session_scope_store: a session-scope rule cannot be removed through the persisted-store entry — session authorizations live in the session's own overlay and end with the session (design/382 §4.3)",
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
if (owner.kind === "local-owner" && provider.forLocalOwner === undefined) {
|
|
332
388
|
return { status: "failed", error: "this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be removed through it" };
|
|
333
389
|
}
|
|
334
|
-
const store = owner.kind === "local-owner" ?
|
|
390
|
+
const store = owner.kind === "local-owner" ? provider.forLocalOwner() : provider.forPrincipal(owner.principal);
|
|
335
391
|
const writer = writerOf(store);
|
|
336
392
|
if (writer === undefined) {
|
|
337
393
|
return { status: "failed", error: "the resolved permission-rule store has no write face — rules cannot be removed through it" };
|
|
@@ -343,11 +399,11 @@ export async function removePersistedRule(opts) {
|
|
|
343
399
|
catch (err) {
|
|
344
400
|
return { status: "failed", error: `could not read the permission-rule store: ${errText(err)}` };
|
|
345
401
|
}
|
|
346
|
-
const live = snapshot.rules.filter((r) => r.rule ===
|
|
402
|
+
const live = snapshot.rules.filter((r) => r.rule === rule && sameScope(r.scope, scope));
|
|
347
403
|
const removedDots = live.flatMap((r) => r.adds.map((a) => a.dot));
|
|
348
404
|
if (removedDots.length === 0)
|
|
349
405
|
return { status: "no-op", rev: snapshot.rev };
|
|
350
|
-
const tombstone = { rule
|
|
406
|
+
const tombstone = { rule, scope, removedDots, deletedBy: await writer.nextDot() };
|
|
351
407
|
let expectedRev = snapshot.rev;
|
|
352
408
|
let lastError = "";
|
|
353
409
|
for (let attempt = 0; attempt < REMOVE_MAX_ATTEMPTS; attempt++) {
|
|
@@ -357,7 +413,7 @@ export async function removePersistedRule(opts) {
|
|
|
357
413
|
let stillLive = false;
|
|
358
414
|
try {
|
|
359
415
|
const after = await store.list();
|
|
360
|
-
stillLive = after.rules.some((r) => r.rule ===
|
|
416
|
+
stillLive = after.rules.some((r) => r.rule === rule && sameScope(r.scope, scope) && isRuleLive(r));
|
|
361
417
|
}
|
|
362
418
|
catch {
|
|
363
419
|
stillLive = false;
|
|
@@ -376,7 +432,7 @@ export async function removePersistedRule(opts) {
|
|
|
376
432
|
const after = await store.list();
|
|
377
433
|
const landed = after.tombstones.some((t) => t.deletedBy.actor === tombstone.deletedBy.actor && t.deletedBy.counter === tombstone.deletedBy.counter);
|
|
378
434
|
return landed
|
|
379
|
-
? { status: "removed", rev: after.rev, stillLive: after.rules.some((r) => r.rule ===
|
|
435
|
+
? { status: "removed", rev: after.rev, stillLive: after.rules.some((r) => r.rule === rule && sameScope(r.scope, scope) && isRuleLive(r)) }
|
|
380
436
|
: { status: "failed", error: `${reason} (read-back confirms no tombstone was written)` };
|
|
381
437
|
}
|
|
382
438
|
catch (err) {
|
|
@@ -407,8 +463,8 @@ export class InMemoryPermissionRuleStore {
|
|
|
407
463
|
}
|
|
408
464
|
async list() {
|
|
409
465
|
return {
|
|
410
|
-
rules: applyTombstones(this.rules, this.tombstones)
|
|
411
|
-
tombstones: this.tombstones
|
|
466
|
+
rules: structuredClone(applyTombstones(this.rules, this.tombstones)),
|
|
467
|
+
tombstones: structuredClone(this.tombstones),
|
|
412
468
|
rev: this.rev,
|
|
413
469
|
};
|
|
414
470
|
}
|
|
@@ -427,13 +483,16 @@ export class InMemoryPermissionRuleStore {
|
|
|
427
483
|
quarantined: this.quarantinedRows,
|
|
428
484
|
}),
|
|
429
485
|
apply: async (delta, opts) => {
|
|
486
|
+
delta = structuredClone(delta);
|
|
430
487
|
if (opts.expectedRev !== this.rev)
|
|
431
488
|
return { conflict: true, rev: this.rev };
|
|
432
489
|
if (delta.kind === "redemption-add") {
|
|
490
|
+
assertWriteDeltaScopeDurable(delta);
|
|
433
491
|
assertRedemptionNotQuarantined(this.quarantinedRows, delta);
|
|
434
492
|
this.rules = foldDelta(this.rules, delta);
|
|
435
493
|
}
|
|
436
494
|
else if (delta.kind === "tighten-delete") {
|
|
495
|
+
assertWriteDeltaScopeDurable(delta);
|
|
437
496
|
assertDeleteDeltaCarriesNoAdd(delta);
|
|
438
497
|
this.tombstones = [...this.tombstones, delta.tombstone];
|
|
439
498
|
}
|
|
@@ -134,6 +134,15 @@ interface ParsedRuleSyncResponse {
|
|
|
134
134
|
dot: RuleDot;
|
|
135
135
|
code: RuleRejectCode;
|
|
136
136
|
}>;
|
|
137
|
+
/** design/382 §4.3 — SESSION-scope tombstones, dropped at the parse as per-row refusals (never
|
|
138
|
+
* `undefined`-malformed): a session row can never land regardless of its dots, so classifying a
|
|
139
|
+
* DAMAGED one as generic-malformed would withhold every valid inbound add on every round — one
|
|
140
|
+
* peer row poisoning sync forever, the exact shape the per-row drop arm exists to avoid. */
|
|
141
|
+
sessionScopeTombstones: Array<{
|
|
142
|
+
rule: string;
|
|
143
|
+
scope: RuleScope;
|
|
144
|
+
dot: RuleDot;
|
|
145
|
+
}>;
|
|
137
146
|
warnings: string[];
|
|
138
147
|
}
|
|
139
148
|
/**
|