@sema-agent/core 7.25.1 → 7.26.1
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 +103 -0
- package/dist/core/ask-origin.d.ts +84 -0
- package/dist/core/ask-origin.js +31 -3
- package/dist/core/auto-compaction.d.ts +5 -0
- package/dist/core/auto-compaction.js +7 -0
- package/dist/core/auto-mode-directive.d.ts +45 -0
- package/dist/core/auto-mode-directive.js +47 -0
- package/dist/core/checkpoint-store.d.ts +10 -1
- package/dist/core/engine-notice.d.ts +10 -0
- package/dist/core/gate-lanes.js +18 -10
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +3 -0
- package/dist/core/hooks.d.ts +3 -0
- package/dist/core/mcp-probe.d.ts +122 -0
- package/dist/core/mcp-probe.js +132 -0
- package/dist/core/memory-engine/engine.d.ts +6 -1
- package/dist/core/memory-engine/engine.js +30 -2
- package/dist/core/memory-engine/index.d.ts +1 -0
- package/dist/core/memory-engine/index.js +1 -0
- package/dist/core/memory-engine/shadowing.d.ts +58 -0
- package/dist/core/memory-engine/shadowing.js +40 -0
- package/dist/core/memory-engine/tools.d.ts +33 -1
- package/dist/core/memory-engine/tools.js +136 -31
- package/dist/core/memory-engine/types.d.ts +10 -0
- package/dist/core/memory.d.ts +60 -0
- package/dist/core/memory.js +47 -2
- package/dist/core/permission-rule-provider.js +6 -1
- package/dist/core/permission-rule-store.d.ts +87 -8
- package/dist/core/permission-rule-store.js +223 -76
- package/dist/core/permission-rule-sync.js +14 -5
- package/dist/core/reminder-mint.d.ts +41 -0
- package/dist/core/reminder-mint.js +55 -0
- package/dist/core/runner/compaction-restate.d.ts +9 -0
- package/dist/core/runner/compaction-restate.js +20 -0
- package/dist/core/runner/contracts.d.ts +18 -1
- package/dist/core/runner/denial-limit-arms.js +1 -1
- package/dist/core/runner/permission-rule-lanes.d.ts +5 -17
- package/dist/core/runner/permission-rule-lanes.js +5 -23
- package/dist/core/runner/prepare-ask-lane.js +2 -0
- package/dist/core/runner/prepare-context-lane.d.ts +4 -0
- package/dist/core/runner/prepare-context-lane.js +7 -2
- package/dist/core/runner/prepare-inherited-gate.d.ts +6 -2
- package/dist/core/runner/prepare-inherited-gate.js +6 -3
- package/dist/core/runner/prepare-memory.js +47 -6
- package/dist/core/runner/prepare-park-ask.js +1 -0
- package/dist/core/runner/prepare-policy-chain.js +8 -4
- package/dist/core/runner/prepare-suspend-saga.d.ts +2 -0
- package/dist/core/runner/prepare-suspend-saga.js +3 -1
- package/dist/core/runner/prepare-task.js +7 -4
- package/dist/core/runner/run-leg.js +9 -1
- package/dist/core/runner/run-recovery-lanes.js +2 -2
- package/dist/core/runner/run-terminal-adoption.js +2 -2
- package/dist/core/runner/run-turn-boundary.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.d.ts +20 -0
- package/dist/core/store-contracts/permission-rule-write-contract.js +90 -0
- 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/task-result.d.ts +11 -0
- package/dist/core/task-spec.d.ts +31 -0
- package/dist/core/tool-catalog-entries.js +3 -3
- package/dist/core/tool-policy.d.ts +38 -0
- package/dist/core/tool-policy.js +5 -1
- package/dist/core/types.d.ts +1 -1
- package/dist/index.d.ts +11 -2
- package/dist/index.js +10 -1
- 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 +52 -31
- 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/package.json +1 -1
- package/test/export-surface.snapshot.json +79 -1
- package/wire-changes/core@7.26.0.json +695 -0
- package/wire-changes/core@7.26.1.json +605 -0
|
@@ -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
|
|
@@ -221,6 +257,19 @@ export interface RuleSyncJoinDelta {
|
|
|
221
257
|
* calling layer, and closes identically for every caller.
|
|
222
258
|
*/
|
|
223
259
|
export type RuleWriteDelta = RuleAddDelta | RuleTightenAddDelta | RuleDeleteDelta | RuleSyncJoinDelta;
|
|
260
|
+
/**
|
|
261
|
+
* The refusal every backend owes a delta whose `kind` is outside {@link RuleWriteDelta} — minted here so
|
|
262
|
+
* the two bundled backends (and an out-of-repo twin) answer an unnamed operation with ONE sentence
|
|
263
|
+
* instead of each guessing.
|
|
264
|
+
*
|
|
265
|
+
* The parameter is `never` on purpose: the only way to reach it is from a `default` arm the type system
|
|
266
|
+
* has already proved unreachable, so the factory doubles as the compile-time fence (a fifth arm added to
|
|
267
|
+
* the union stops satisfying `never` and reds at every backend's switch, naming the missing word). The
|
|
268
|
+
* runtime half is not decoration — a delta arrives as DATA (a foreign caller, a replayed record, a
|
|
269
|
+
* deserialized join), and the alternative to throwing is folding an unnamed kind as whichever arm
|
|
270
|
+
* an if/else chain happens to end with: a write nobody defined, landed under a name nobody checked.
|
|
271
|
+
*/
|
|
272
|
+
export declare function unknownRuleWriteDeltaError(delta: never): Error;
|
|
224
273
|
/**
|
|
225
274
|
* The backend write face. Exported as part of the BACKEND CONTRACT (ruled 2026-08-10) so an
|
|
226
275
|
* out-of-repo store implementation hangs the same face the file backend does, instead of mirroring the
|
|
@@ -531,6 +580,22 @@ export declare function ruleStoreChecksum(payload: unknown): Promise<string>;
|
|
|
531
580
|
*/
|
|
532
581
|
export declare const REMOVAL_LIVENESS_WORDS: readonly ["yes", "no", "unknown"];
|
|
533
582
|
export type RemovalLiveness = (typeof REMOVAL_LIVENESS_WORDS)[number];
|
|
583
|
+
/**
|
|
584
|
+
* What a terminal write FAILURE may claim about the bytes — the failure half of the honesty
|
|
585
|
+
* {@link RemovalLiveness} keeps on the success half, spelled ONCE and read by every write entry here.
|
|
586
|
+
*
|
|
587
|
+
* `"no"` = the store ITSELF answered: a face that cannot write at all, a target the type face refuses,
|
|
588
|
+
* a read that failed before any write, a refused preflight, an epoch seal, a compare-and-set that lost
|
|
589
|
+
* every attempt. Each of those is the store saying nothing landed, so saying it is a fact.
|
|
590
|
+
* `"unknown"` = the apply THREW, which proves nothing either way (a backend can commit and then lose
|
|
591
|
+
* its acknowledgement), or the read-back that would have settled it could not be taken.
|
|
592
|
+
*
|
|
593
|
+
* The word exists because the caller's two branches — retry, or reconcile with `list()` — are not
|
|
594
|
+
* derivable from the failure PROSE. A consumer reading the strength out of the sentence is parsing a
|
|
595
|
+
* message written for a person, and every such reader has to be updated by hand when the sentence is.
|
|
596
|
+
*/
|
|
597
|
+
export declare const RULE_WRITE_COMMITTED_WORDS: readonly ["no", "unknown"];
|
|
598
|
+
export type RuleWriteCommitted = (typeof RULE_WRITE_COMMITTED_WORDS)[number];
|
|
534
599
|
/**
|
|
535
600
|
* The result of a removal.
|
|
536
601
|
*
|
|
@@ -548,21 +613,32 @@ export type RemoveResult =
|
|
|
548
613
|
rev: number;
|
|
549
614
|
stillLive: RemovalLiveness;
|
|
550
615
|
}
|
|
551
|
-
/** Nothing to remove — including a repeated delete. No empty tombstone is minted.
|
|
616
|
+
/** Nothing to remove — including a repeated delete. No empty tombstone is minted. `stillLive` is the
|
|
617
|
+
* SAME read-back the `removed` arm reports, taken at the same point in the call ({@link
|
|
618
|
+
* readRuleLiveness}): "there was nothing to remove" is a statement about the snapshot this call read,
|
|
619
|
+
* and an approval landing after it leaves the rule standing. Both 200-shaped arms therefore carry the
|
|
620
|
+
* word, from one source. It is a LATER OBSERVATION, not an atomic absence: `rev` is the revision the
|
|
621
|
+
* decision was taken at, the word is what a second look saw, and the store may change again right
|
|
622
|
+
* after — `yes` beside a `no-op` is the honest pair, not a contradiction. */
|
|
552
623
|
| {
|
|
553
624
|
status: "no-op";
|
|
554
625
|
rev: number;
|
|
626
|
+
stillLive: RemovalLiveness;
|
|
555
627
|
}
|
|
556
|
-
/** Terminal failure, in one of two strengths
|
|
557
|
-
* {@link
|
|
558
|
-
*
|
|
628
|
+
/** Terminal failure, in one of two strengths — word for word the dual of {@link AddResult}'s, and
|
|
629
|
+
* `committed` is the word for it ({@link RuleWriteCommitted}), so the strength is READ, not parsed out
|
|
630
|
+
* of the sentence. When the STORE answered every attempt (the compare-and-set simply lost), nothing
|
|
631
|
+
* landed: `committed: "no"`, and the read-back confirms it in the text. When the apply THREW, a missing
|
|
559
632
|
* tombstone cannot tell a write that never landed from one whose evidence a paired collection erased
|
|
560
633
|
* (`collectBelowFrontier` recycles a tombstone together with the adds it covered), so that outcome is
|
|
561
|
-
* reported INDETERMINATE and the caller reconciles with `list()` rather than
|
|
562
|
-
* read-back itself failed,
|
|
634
|
+
* `committed: "unknown"`, reported INDETERMINATE, and the caller reconciles with `list()` rather than
|
|
635
|
+
* retrying blind. When the read-back itself failed, the word is the one the apply earned — a throw
|
|
636
|
+
* leaves it `unknown`, an answered compare-and-set stays `no`, because an unreadable store cannot
|
|
637
|
+
* un-answer what the store already said. */
|
|
563
638
|
| {
|
|
564
639
|
status: "failed";
|
|
565
640
|
error: string;
|
|
641
|
+
committed: RuleWriteCommitted;
|
|
566
642
|
};
|
|
567
643
|
/**
|
|
568
644
|
* Remove a persisted rule.
|
|
@@ -602,7 +678,7 @@ export type RuleAddLanding = {
|
|
|
602
678
|
| {
|
|
603
679
|
landed: false;
|
|
604
680
|
reason: string;
|
|
605
|
-
committed:
|
|
681
|
+
committed: RuleWriteCommitted;
|
|
606
682
|
};
|
|
607
683
|
/**
|
|
608
684
|
* Land ONE add — the terminal write both doors take (the consent protocol's redemption and the
|
|
@@ -652,10 +728,13 @@ export type AddResult =
|
|
|
652
728
|
* survives the tombstone a concurrent removal laid on it — but a sync collection can erase even
|
|
653
729
|
* that, so a dot it cannot find is reported INDETERMINATE rather than as a confirmed nothing, and
|
|
654
730
|
* the caller reconciles with `list()` instead of retrying blind (a blind retry mints a fresh dot and
|
|
655
|
-
* can reinstate a restriction somebody had just removed).
|
|
731
|
+
* can reinstate a restriction somebody had just removed). `committed` ({@link RuleWriteCommitted}) is
|
|
732
|
+
* which of the two this is, as a WORD — the landing's own answer, passed through rather than re-derived,
|
|
733
|
+
* so this entry and the consent protocol's door cannot disagree about a write they both took. */
|
|
656
734
|
| {
|
|
657
735
|
status: "failed";
|
|
658
736
|
error: string;
|
|
737
|
+
committed: RuleWriteCommitted;
|
|
659
738
|
};
|
|
660
739
|
/**
|
|
661
740
|
* Add a persisted rule in ONE step, with no approval record.
|
|
@@ -5,6 +5,75 @@ import { thrownText } from "./thrown-value.js";
|
|
|
5
5
|
export function sameRuleOwner(a, b) {
|
|
6
6
|
return a.kind === "principal" ? b.kind === "principal" && a.principal === b.principal : b.kind === "local-owner";
|
|
7
7
|
}
|
|
8
|
+
export function isRuleRevision(rev) {
|
|
9
|
+
return typeof rev === "number" && Number.isSafeInteger(rev) && rev >= 0;
|
|
10
|
+
}
|
|
11
|
+
function outcomeWord(value) {
|
|
12
|
+
if (value === undefined)
|
|
13
|
+
return "no revision at all";
|
|
14
|
+
if (value === null)
|
|
15
|
+
return "a null revision";
|
|
16
|
+
if (typeof value === "number")
|
|
17
|
+
return `the number ${String(value)}, which is not a revision`;
|
|
18
|
+
return `a ${typeof value} where a revision belongs`;
|
|
19
|
+
}
|
|
20
|
+
function isReadableDrop(drop) {
|
|
21
|
+
if (drop === null || typeof drop !== "object")
|
|
22
|
+
return false;
|
|
23
|
+
const dot = drop.dot;
|
|
24
|
+
if (dot === null || typeof dot !== "object")
|
|
25
|
+
return false;
|
|
26
|
+
return typeof dot.actor === "string" && typeof dot.counter === "number" && typeof drop.reason === "string";
|
|
27
|
+
}
|
|
28
|
+
function readSyncReport(sync) {
|
|
29
|
+
if (sync === undefined)
|
|
30
|
+
return {};
|
|
31
|
+
if (sync === null || typeof sync !== "object")
|
|
32
|
+
return { unreadable: `the landing carried a ${sync === null ? "null" : typeof sync} where its sync report belongs` };
|
|
33
|
+
for (const member of ["droppedInbound", "quarantined"]) {
|
|
34
|
+
const value = sync[member];
|
|
35
|
+
if (value === undefined)
|
|
36
|
+
continue;
|
|
37
|
+
if (!Array.isArray(value))
|
|
38
|
+
return { unreadable: `the landing's sync report carried a ${typeof value} as \`${member}\`, which a reader cannot walk` };
|
|
39
|
+
const bad = value.findIndex((row) => !isReadableDrop(row));
|
|
40
|
+
if (bad !== -1)
|
|
41
|
+
return { unreadable: `the landing's sync report carried a \`${member}\` entry (#${String(bad)}) that is not a drop record, so what was dropped cannot be read` };
|
|
42
|
+
}
|
|
43
|
+
return { report: sync };
|
|
44
|
+
}
|
|
45
|
+
export function readRuleWriteOutcome(res) {
|
|
46
|
+
if (res === null || typeof res !== "object") {
|
|
47
|
+
return { kind: "unreadable", why: `the store answered ${res === null ? "null" : `a ${typeof res}`} where a write outcome belongs` };
|
|
48
|
+
}
|
|
49
|
+
const tagged = res;
|
|
50
|
+
const refusal = tagged.refused;
|
|
51
|
+
const conflict = tagged.conflict;
|
|
52
|
+
if (refusal !== undefined && conflict !== undefined) {
|
|
53
|
+
return { kind: "unreadable", why: "the store answered a refusal and a decline at once, and an answer that is two arms is not an answer about either" };
|
|
54
|
+
}
|
|
55
|
+
if (refusal !== undefined) {
|
|
56
|
+
return typeof refusal === "string" && refusal !== ""
|
|
57
|
+
? { kind: "refused", refused: refusal }
|
|
58
|
+
: { kind: "unreadable", why: `the store tagged its answer as a refusal and named it with a ${refusal === null ? "null" : typeof refusal}, so which refusal it is cannot be read` };
|
|
59
|
+
}
|
|
60
|
+
if (conflict !== undefined) {
|
|
61
|
+
if (conflict !== true)
|
|
62
|
+
return { kind: "unreadable", why: `the store tagged its answer as a decline with \`${String(conflict)}\`, which does not say a compare-and-set lost` };
|
|
63
|
+
return isRuleRevision(tagged.rev)
|
|
64
|
+
? { kind: "conflict", rev: tagged.rev }
|
|
65
|
+
: { kind: "unreadable", why: `the store declined the write with ${outcomeWord(tagged.rev)}, so the revision to re-anchor the next attempt to is unknown` };
|
|
66
|
+
}
|
|
67
|
+
if (!isRuleRevision(tagged.rev)) {
|
|
68
|
+
return { kind: "unreadable", why: `the store answered neither a conflict nor a refusal, and carried ${outcomeWord(tagged.rev)} — a landing reports the revision it committed at, so this answer cannot say whether the write landed` };
|
|
69
|
+
}
|
|
70
|
+
const { report, unreadable } = readSyncReport(tagged.sync);
|
|
71
|
+
return { kind: "landed", rev: tagged.rev, ...(report !== undefined ? { sync: report } : {}), ...(unreadable !== undefined ? { reportUnreadable: unreadable } : {}) };
|
|
72
|
+
}
|
|
73
|
+
export function unknownRuleWriteDeltaError(delta) {
|
|
74
|
+
const kind = delta?.kind;
|
|
75
|
+
return new Error(`the permission-rule store cannot apply a write delta of kind ${escapeForDisclosure(typeof kind === "string" ? kind : String(kind))} — the write face folds exactly four named arms (redemption-add, tighten-add, tighten-delete, sync-join), and an unnamed one is refused rather than read as any of them`);
|
|
76
|
+
}
|
|
8
77
|
export const PERMISSION_RULE_WRITER = "__semaPermissionRuleWriter";
|
|
9
78
|
export function writerOf(store) {
|
|
10
79
|
const w = store[PERMISSION_RULE_WRITER];
|
|
@@ -394,6 +463,50 @@ export async function ruleStoreChecksum(payload) {
|
|
|
394
463
|
return `sha256:${createHash("sha256").update(canonicalize(payload), "utf8").digest("hex")}`;
|
|
395
464
|
}
|
|
396
465
|
export const REMOVAL_LIVENESS_WORDS = ["yes", "no", "unknown"];
|
|
466
|
+
export const RULE_WRITE_COMMITTED_WORDS = ["no", "unknown"];
|
|
467
|
+
function resolveRuleWriter(store) {
|
|
468
|
+
let w;
|
|
469
|
+
try {
|
|
470
|
+
w = writerOf(store);
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
return { unavailable: `the permission-rule store's write face could not be acquired (${errText(err)})`, committed: "unknown" };
|
|
474
|
+
}
|
|
475
|
+
return w === undefined ? { unavailable: "the resolved permission-rule store has no write face", committed: "no" } : { writer: w };
|
|
476
|
+
}
|
|
477
|
+
function ruleIdentityStands(rules, identity) {
|
|
478
|
+
return rules.some((r) => sameRuleIdentity(r, identity) && isRuleLive(r));
|
|
479
|
+
}
|
|
480
|
+
async function witnessRuleAdd(store, writer, delta) {
|
|
481
|
+
const identity = { behavior: delta.behavior, rule: delta.rule, scope: delta.scope };
|
|
482
|
+
const dot = delta.add.dot;
|
|
483
|
+
try {
|
|
484
|
+
if (delta.scope.kind === "session") {
|
|
485
|
+
const view = await store.effective({ sessionId: delta.scope.sessionId });
|
|
486
|
+
if (view.unreadable !== undefined)
|
|
487
|
+
return { unreadable: view.unreadable };
|
|
488
|
+
return { seen: view.rules.some((r) => sameRuleIdentity(r, identity) && r.adds.some((a) => sameDot(a.dot, dot))), rev: view.rev };
|
|
489
|
+
}
|
|
490
|
+
const raw = await writer.readRaw();
|
|
491
|
+
const inRows = raw.rules.some((r) => sameRuleIdentity(r, identity) && r.adds.some((a) => sameDot(a.dot, dot)));
|
|
492
|
+
const inQuarantine = (raw.quarantined ?? []).some((q) => sameDot(q.add.dot, dot));
|
|
493
|
+
return { seen: inRows || inQuarantine, rev: raw.rev };
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
return { unreadable: errText(err) };
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async function readRuleLiveness(store, identity) {
|
|
500
|
+
try {
|
|
501
|
+
const after = await store.effective();
|
|
502
|
+
if (after.unreadable !== undefined)
|
|
503
|
+
throw new Error(after.unreadable);
|
|
504
|
+
return ruleIdentityStands(after.rules, identity) ? "yes" : "no";
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
return "unknown";
|
|
508
|
+
}
|
|
509
|
+
}
|
|
397
510
|
const REMOVE_MAX_ATTEMPTS = 8;
|
|
398
511
|
export async function removePersistedRule(opts) {
|
|
399
512
|
const behavior = opts.behavior;
|
|
@@ -401,7 +514,7 @@ export async function removePersistedRule(opts) {
|
|
|
401
514
|
const scope = structuredClone(opts.scope);
|
|
402
515
|
const target = opts.principal;
|
|
403
516
|
if (!isRuleBehavior(behavior)) {
|
|
404
|
-
return { status: "failed", error: `removePersistedRule: behavior ${escapeForDisclosure(behavior)} is not one of ${["deny", "ask", "allow"].join("/")} — refusing to name a rule identity nothing can carry
|
|
517
|
+
return { status: "failed", error: `removePersistedRule: behavior ${escapeForDisclosure(behavior)} is not one of ${["deny", "ask", "allow"].join("/")} — refusing to name a rule identity nothing can carry`, committed: "no" };
|
|
405
518
|
}
|
|
406
519
|
const identity = { behavior, rule, scope };
|
|
407
520
|
const owner = typeof target === "string" ? { kind: "principal", principal: target } : structuredClone(target);
|
|
@@ -410,80 +523,92 @@ export async function removePersistedRule(opts) {
|
|
|
410
523
|
return {
|
|
411
524
|
status: "failed",
|
|
412
525
|
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)",
|
|
526
|
+
committed: "no",
|
|
413
527
|
};
|
|
414
528
|
}
|
|
415
529
|
if (owner.kind === "local-owner" && provider.forLocalOwner === undefined) {
|
|
416
|
-
return { status: "failed", error: "this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be removed through it" };
|
|
530
|
+
return { status: "failed", error: "this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be removed through it", committed: "no" };
|
|
417
531
|
}
|
|
418
532
|
const store = owner.kind === "local-owner" ? provider.forLocalOwner() : provider.forPrincipal(owner.principal);
|
|
419
|
-
const
|
|
420
|
-
if (
|
|
421
|
-
return { status: "failed", error:
|
|
533
|
+
const face = resolveRuleWriter(store);
|
|
534
|
+
if ("unavailable" in face) {
|
|
535
|
+
return { status: "failed", error: `${face.unavailable} — rules cannot be removed through it`, committed: face.committed };
|
|
422
536
|
}
|
|
537
|
+
const writer = face.writer;
|
|
423
538
|
let snapshot;
|
|
424
539
|
try {
|
|
425
540
|
snapshot = await store.effective();
|
|
426
541
|
}
|
|
427
542
|
catch (err) {
|
|
428
|
-
return { status: "failed", error: `could not read the permission-rule store: ${errText(err)}
|
|
543
|
+
return { status: "failed", error: `could not read the permission-rule store: ${errText(err)}`, committed: "no" };
|
|
429
544
|
}
|
|
430
545
|
if (snapshot.unreadable !== undefined)
|
|
431
|
-
return { status: "failed", error: `could not read the permission-rule store: ${snapshot.unreadable}
|
|
546
|
+
return { status: "failed", error: `could not read the permission-rule store: ${snapshot.unreadable}`, committed: "no" };
|
|
432
547
|
const live = snapshot.rules.filter((r) => sameRuleIdentity(r, identity));
|
|
433
548
|
const removedDots = live.flatMap((r) => r.adds.map((a) => a.dot));
|
|
434
549
|
if (removedDots.length === 0)
|
|
435
|
-
return { status: "no-op", rev: snapshot.rev };
|
|
436
|
-
|
|
550
|
+
return { status: "no-op", rev: snapshot.rev, stillLive: await readRuleLiveness(store, identity) };
|
|
551
|
+
let deletedBy;
|
|
552
|
+
try {
|
|
553
|
+
deletedBy = await writer.nextDot();
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
return { status: "failed", error: `could not mint the removal's delete dot: ${errText(err)}`, committed: "no" };
|
|
557
|
+
}
|
|
558
|
+
const tombstone = { behavior, rule, scope, removedDots, deletedBy };
|
|
437
559
|
let expectedRev = snapshot.rev;
|
|
438
560
|
let lastError = "";
|
|
439
|
-
let
|
|
561
|
+
let settlement = "no";
|
|
440
562
|
for (let attempt = 0; attempt < REMOVE_MAX_ATTEMPTS; attempt++) {
|
|
441
563
|
try {
|
|
442
|
-
const
|
|
443
|
-
if ("refused"
|
|
444
|
-
return { status: "failed", error: `the store refused the tombstone (${
|
|
564
|
+
const reading = readRuleWriteOutcome(await writer.apply({ kind: "tighten-delete", tombstone }, { expectedRev }));
|
|
565
|
+
if (reading.kind === "refused") {
|
|
566
|
+
return { status: "failed", error: `the store refused the tombstone (${reading.refused}) — nothing was removed`, committed: "no" };
|
|
445
567
|
}
|
|
446
|
-
if (
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
if (after.unreadable !== undefined)
|
|
451
|
-
throw new Error(after.unreadable);
|
|
452
|
-
stillLive = after.rules.some((r) => sameRuleIdentity(r, identity) && isRuleLive(r)) ? "yes" : "no";
|
|
453
|
-
}
|
|
454
|
-
catch {
|
|
455
|
-
stillLive = "unknown";
|
|
456
|
-
}
|
|
457
|
-
return { status: "removed", rev: res.rev, stillLive };
|
|
568
|
+
if (reading.kind === "unreadable") {
|
|
569
|
+
settlement = "unknown";
|
|
570
|
+
lastError = `the permission-rule store answered the write unreadably: ${reading.why}`;
|
|
571
|
+
break;
|
|
458
572
|
}
|
|
459
|
-
|
|
573
|
+
if (reading.kind === "landed") {
|
|
574
|
+
return { status: "removed", rev: reading.rev, stillLive: await readRuleLiveness(store, identity) };
|
|
575
|
+
}
|
|
576
|
+
expectedRev = reading.rev;
|
|
460
577
|
}
|
|
461
578
|
catch (err) {
|
|
462
|
-
|
|
579
|
+
settlement = "unknown";
|
|
463
580
|
lastError = errText(err);
|
|
464
581
|
break;
|
|
465
582
|
}
|
|
466
583
|
}
|
|
467
|
-
const reason =
|
|
584
|
+
const reason = settlement === "unknown"
|
|
468
585
|
? lastError !== ""
|
|
469
586
|
? lastError
|
|
470
587
|
: "the permission-rule store threw while applying the tombstone, with no message"
|
|
471
588
|
: `optimistic-concurrency retries exhausted after ${REMOVE_MAX_ATTEMPTS} attempts`;
|
|
589
|
+
const committed = settlement;
|
|
472
590
|
try {
|
|
473
591
|
const after = await writer.readRaw();
|
|
474
592
|
const landed = after.tombstones.some((t) => t.deletedBy.actor === tombstone.deletedBy.actor && t.deletedBy.counter === tombstone.deletedBy.counter);
|
|
475
593
|
if (landed) {
|
|
476
|
-
return { status: "removed", rev: after.rev, stillLive: applyTombstones(after.rules, after.tombstones)
|
|
594
|
+
return { status: "removed", rev: after.rev, stillLive: ruleIdentityStands(applyTombstones(after.rules, after.tombstones), identity) ? "yes" : "no" };
|
|
477
595
|
}
|
|
478
596
|
return {
|
|
479
597
|
status: "failed",
|
|
480
|
-
error:
|
|
598
|
+
error: settlement === "unknown"
|
|
481
599
|
? `${reason}; the store retains no tombstone for this removal, which cannot tell a write that never landed from one whose evidence was collected — the outcome is indeterminate, reconcile with list()`
|
|
482
600
|
: `${reason} (read-back confirms no tombstone was written)`,
|
|
601
|
+
committed,
|
|
483
602
|
};
|
|
484
603
|
}
|
|
485
604
|
catch (err) {
|
|
486
|
-
return {
|
|
605
|
+
return {
|
|
606
|
+
status: "failed",
|
|
607
|
+
error: settlement === "unknown"
|
|
608
|
+
? `${reason}; the read-back also failed (${errText(err)}) so this outcome is indeterminate — reconcile with list()`
|
|
609
|
+
: `${reason}; the read-back also failed (${errText(err)}), but every attempt was answered by the store, so nothing was written`,
|
|
610
|
+
committed,
|
|
611
|
+
};
|
|
487
612
|
}
|
|
488
613
|
}
|
|
489
614
|
const RULE_ADD_MAX_ATTEMPTS = 8;
|
|
@@ -519,10 +644,21 @@ export async function landRuleAdd(args) {
|
|
|
519
644
|
catch (err) {
|
|
520
645
|
return { landed: false, reason: `the permission-rule store refused the write: ${errText(err)}`, committed: "unknown" };
|
|
521
646
|
}
|
|
522
|
-
|
|
647
|
+
const reading = readRuleWriteOutcome(res);
|
|
648
|
+
if (reading.kind === "refused")
|
|
523
649
|
return { landed: false, reason: SESSION_ENDED_REFUSAL, committed: "no" };
|
|
524
|
-
if (
|
|
525
|
-
return { landed:
|
|
650
|
+
if (reading.kind === "unreadable")
|
|
651
|
+
return { landed: false, reason: `the permission-rule store answered the write unreadably: ${reading.why}`, committed: "unknown" };
|
|
652
|
+
if (reading.kind === "landed") {
|
|
653
|
+
const witness = await witnessRuleAdd(args.store, args.writer, args.delta);
|
|
654
|
+
if ("unreadable" in witness) {
|
|
655
|
+
return { landed: false, reason: `the permission-rule store reported a landing at revision ${String(reading.rev)}, and the read-back that would have witnessed it could not be taken (${witness.unreadable})`, committed: "unknown" };
|
|
656
|
+
}
|
|
657
|
+
if (!witness.seen) {
|
|
658
|
+
return { landed: false, reason: `the permission-rule store reported a landing at revision ${String(reading.rev)}, and a read-back of this add's own dot finds no trace of it — a revision says the ANSWER was well formed, not that a row exists, so nothing here can say the write landed`, committed: "unknown" };
|
|
659
|
+
}
|
|
660
|
+
return { landed: true, rev: reading.rev };
|
|
661
|
+
}
|
|
526
662
|
}
|
|
527
663
|
return { landed: false, reason: `optimistic-concurrency retries exhausted after ${RULE_ADD_MAX_ATTEMPTS} attempts`, committed: "no" };
|
|
528
664
|
}
|
|
@@ -532,41 +668,43 @@ export async function addPersistedRule(opts) {
|
|
|
532
668
|
const scope = structuredClone(opts.scope);
|
|
533
669
|
const target = opts.principal;
|
|
534
670
|
if (!isRuleBehavior(behavior)) {
|
|
535
|
-
return { status: "failed", error: `addPersistedRule: behavior ${escapeForDisclosure(behavior)} is not one of ${["deny", "ask", "allow"].join("/")} — refusing to name a rule identity nothing can carry
|
|
671
|
+
return { status: "failed", error: `addPersistedRule: behavior ${escapeForDisclosure(behavior)} is not one of ${["deny", "ask", "allow"].join("/")} — refusing to name a rule identity nothing can carry`, committed: "no" };
|
|
536
672
|
}
|
|
537
673
|
if (behavior === "allow") {
|
|
538
674
|
return {
|
|
539
675
|
status: "failed",
|
|
540
676
|
error: "addPersistedRule: an allow cannot be written through this entry — a standing approval is a person's yes and takes a consent record (prepare → confirm → redeem); this entry writes the tightening direction (deny/ask) only",
|
|
677
|
+
committed: "no",
|
|
541
678
|
};
|
|
542
679
|
}
|
|
543
680
|
if (!isValidConsentScope(scope)) {
|
|
544
|
-
return { status: "failed", error: `addPersistedRule: ${escapeForDisclosure(JSON.stringify(scope))} is not a valid rule scope — refusing to write a row nothing can read back
|
|
681
|
+
return { status: "failed", error: `addPersistedRule: ${escapeForDisclosure(JSON.stringify(scope))} is not a valid rule scope — refusing to write a row nothing can read back`, committed: "no" };
|
|
545
682
|
}
|
|
546
683
|
const parsed = parseRuleText(ruleText, behavior);
|
|
547
684
|
if ("reject" in parsed)
|
|
548
|
-
return { status: "failed", error: `addPersistedRule: ${parsed.reject.code}: ${parsed.reject.message}
|
|
685
|
+
return { status: "failed", error: `addPersistedRule: ${parsed.reject.code}: ${parsed.reject.message}`, committed: "no" };
|
|
549
686
|
const identity = { behavior, rule: parsed.rule.rule, scope };
|
|
550
687
|
const owner = typeof target === "string" ? { kind: "principal", principal: target } : structuredClone(target);
|
|
551
688
|
const provider = opts.provider;
|
|
552
689
|
if (owner.kind === "local-owner" && provider.forLocalOwner === undefined) {
|
|
553
|
-
return { status: "failed", error: "this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be added through it" };
|
|
690
|
+
return { status: "failed", error: "this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be added through it", committed: "no" };
|
|
554
691
|
}
|
|
555
692
|
const store = owner.kind === "local-owner" ? provider.forLocalOwner() : provider.forPrincipal(owner.principal);
|
|
556
|
-
const
|
|
557
|
-
if (
|
|
558
|
-
return { status: "failed", error:
|
|
693
|
+
const face = resolveRuleWriter(store);
|
|
694
|
+
if ("unavailable" in face) {
|
|
695
|
+
return { status: "failed", error: `${face.unavailable} — rules cannot be added through it`, committed: face.committed };
|
|
559
696
|
}
|
|
697
|
+
const writer = face.writer;
|
|
560
698
|
const query = scope.kind === "session" ? { sessionId: scope.sessionId } : undefined;
|
|
561
699
|
let snapshot;
|
|
562
700
|
try {
|
|
563
701
|
snapshot = await store.effective(query);
|
|
564
702
|
}
|
|
565
703
|
catch (err) {
|
|
566
|
-
return { status: "failed", error: `could not read the permission-rule store: ${errText(err)}
|
|
704
|
+
return { status: "failed", error: `could not read the permission-rule store: ${errText(err)}`, committed: "no" };
|
|
567
705
|
}
|
|
568
706
|
if (snapshot.unreadable !== undefined)
|
|
569
|
-
return { status: "failed", error: `could not read the permission-rule store: ${snapshot.unreadable}
|
|
707
|
+
return { status: "failed", error: `could not read the permission-rule store: ${snapshot.unreadable}`, committed: "no" };
|
|
570
708
|
if (snapshot.rules.some((r) => sameRuleIdentity(r, identity) && isRuleLive(r)))
|
|
571
709
|
return { status: "no-op", rev: snapshot.rev };
|
|
572
710
|
let dot;
|
|
@@ -574,7 +712,7 @@ export async function addPersistedRule(opts) {
|
|
|
574
712
|
dot = await writer.nextDot();
|
|
575
713
|
}
|
|
576
714
|
catch (err) {
|
|
577
|
-
return { status: "failed", error: `could not mint the add's dot: ${errText(err)}
|
|
715
|
+
return { status: "failed", error: `could not mint the add's dot: ${errText(err)}`, committed: "no" };
|
|
578
716
|
}
|
|
579
717
|
const landing = await landRuleAdd({
|
|
580
718
|
store,
|
|
@@ -593,7 +731,7 @@ export async function addPersistedRule(opts) {
|
|
|
593
731
|
if (landing.landed)
|
|
594
732
|
return { status: "added", rev: landing.rev, dot };
|
|
595
733
|
if (landing.committed === "no")
|
|
596
|
-
return { status: "failed", error: landing.reason };
|
|
734
|
+
return { status: "failed", error: landing.reason, committed: landing.committed };
|
|
597
735
|
try {
|
|
598
736
|
let rev;
|
|
599
737
|
let rows;
|
|
@@ -613,10 +751,10 @@ export async function addPersistedRule(opts) {
|
|
|
613
751
|
quarantinedRows.some((q) => sameDot(q.add.dot, dot));
|
|
614
752
|
return stood
|
|
615
753
|
? { status: "added", rev, dot }
|
|
616
|
-
: { status: "failed", error: `${landing.reason}; the store retains no trace of this add, which cannot tell a write that never landed from one whose evidence was collected — the outcome is indeterminate, reconcile with list()
|
|
754
|
+
: { status: "failed", error: `${landing.reason}; the store retains no trace of this add, which cannot tell a write that never landed from one whose evidence was collected — the outcome is indeterminate, reconcile with list()`, committed: landing.committed };
|
|
617
755
|
}
|
|
618
756
|
catch (err) {
|
|
619
|
-
return { status: "failed", error: `${landing.reason}; the read-back also failed (${errText(err)}) so this outcome is indeterminate — reconcile with list()
|
|
757
|
+
return { status: "failed", error: `${landing.reason}; the read-back also failed (${errText(err)}) so this outcome is indeterminate — reconcile with list()`, committed: landing.committed };
|
|
620
758
|
}
|
|
621
759
|
}
|
|
622
760
|
export function errText(err) {
|
|
@@ -666,35 +804,44 @@ export class InMemoryDurableRulePartition {
|
|
|
666
804
|
delta = structuredClone(delta);
|
|
667
805
|
if (opts.expectedRev !== this.rev)
|
|
668
806
|
return { conflict: true, rev: this.rev };
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
807
|
+
switch (delta.kind) {
|
|
808
|
+
case "redemption-add": {
|
|
809
|
+
assertWriteDeltaScopeDurable(delta);
|
|
810
|
+
assertRedemptionNotQuarantined(this.quarantinedRows, delta);
|
|
811
|
+
this.rules = foldDelta(this.rules, delta);
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
case "tighten-add": {
|
|
815
|
+
assertWriteDeltaScopeDurable(delta);
|
|
816
|
+
this.rules = foldDelta(this.rules, delta);
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
case "tighten-delete": {
|
|
820
|
+
assertWriteDeltaScopeDurable(delta);
|
|
821
|
+
assertDeleteDeltaCarriesNoAdd(delta);
|
|
822
|
+
this.tombstones = [...this.tombstones, delta.tombstone];
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
case "sync-join": {
|
|
826
|
+
const { next, report } = applySyncJoin({
|
|
827
|
+
actor: this.actor,
|
|
828
|
+
counter: this.counter,
|
|
829
|
+
rules: this.rules,
|
|
830
|
+
tombstones: this.tombstones,
|
|
831
|
+
quarantined: this.quarantinedRows,
|
|
832
|
+
...(this.observedVector !== undefined ? { observedVector: this.observedVector } : {}),
|
|
833
|
+
}, delta, this.now());
|
|
834
|
+
this.rules = next.rules;
|
|
835
|
+
this.tombstones = next.tombstones;
|
|
836
|
+
this.quarantinedRows = next.quarantined;
|
|
837
|
+
this.observedVector = next.observedVector;
|
|
838
|
+
this.rev += 1;
|
|
839
|
+
return { rev: this.rev, sync: report };
|
|
840
|
+
}
|
|
841
|
+
default: {
|
|
842
|
+
const unhandled = delta;
|
|
843
|
+
throw unknownRuleWriteDeltaError(unhandled);
|
|
844
|
+
}
|
|
698
845
|
}
|
|
699
846
|
this.rev += 1;
|
|
700
847
|
return { rev: this.rev };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { escapeForDisclosure, isRuleBehavior, parseRuleText } from "./permission-rule-model.js";
|
|
2
|
-
import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, ruleSyncVector, sameRuleIdentity, screenRuleSyncState, writerOf, } from "./permission-rule-store.js";
|
|
2
|
+
import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, readRuleWriteOutcome, ruleSyncVector, sameRuleIdentity, screenRuleSyncState, writerOf, } from "./permission-rule-store.js";
|
|
3
3
|
import { emitTrace } from "./trace.js";
|
|
4
4
|
export const PERMISSION_RULE_SYNC_PATH = "/v1/permission-rules/sync";
|
|
5
5
|
export const LOCAL_OWNER_UNSYNCABLE_CODE = "permission_rules.local_owner_unsyncable";
|
|
@@ -174,11 +174,12 @@ export async function syncPermissionRules(opts) {
|
|
|
174
174
|
warnings: [...warnings, `the store refused the sync landing: ${escapeForDisclosure(errText(err))} — nothing landed, the local state is unchanged`],
|
|
175
175
|
});
|
|
176
176
|
}
|
|
177
|
-
|
|
177
|
+
const reading = readRuleWriteOutcome(res);
|
|
178
|
+
if (reading.kind === "conflict") {
|
|
178
179
|
current = await writer.readRaw();
|
|
179
180
|
continue;
|
|
180
181
|
}
|
|
181
|
-
if ("refused"
|
|
182
|
+
if (reading.kind === "refused" || reading.kind === "unreadable") {
|
|
182
183
|
return disclose({
|
|
183
184
|
ok: false,
|
|
184
185
|
pushed: { addDots: raw.rules.reduce((n, r) => n + r.adds.length, 0), tombstones: raw.tombstones.length },
|
|
@@ -186,7 +187,12 @@ export async function syncPermissionRules(opts) {
|
|
|
186
187
|
resurrected: [],
|
|
187
188
|
dropped,
|
|
188
189
|
rev: current.rev,
|
|
189
|
-
warnings: [
|
|
190
|
+
warnings: [
|
|
191
|
+
...warnings,
|
|
192
|
+
reading.kind === "refused"
|
|
193
|
+
? `the store refused the sync landing (${reading.refused}) — nothing landed, the local state is unchanged`
|
|
194
|
+
: `the store answered the sync landing unreadably: ${reading.why} — this round is not reported as landed; re-read before trusting the local state`,
|
|
195
|
+
],
|
|
190
196
|
});
|
|
191
197
|
}
|
|
192
198
|
const landedRaw = await writer.readRaw();
|
|
@@ -211,7 +217,10 @@ export async function syncPermissionRules(opts) {
|
|
|
211
217
|
const tombKeyOf = (t) => JSON.stringify([t.behavior, t.rule, t.scope.kind, t.scope.kind === "project" ? t.scope.root : t.scope.kind === "session" ? t.scope.sessionId : null, dotKeyOf(t.deletedBy)]);
|
|
212
218
|
const preTombs = new Set(current.tombstones.map(tombKeyOf));
|
|
213
219
|
const newTombstones = landedRaw.tombstones.filter((t) => !preTombs.has(tombKeyOf(t))).length;
|
|
214
|
-
const landedReport =
|
|
220
|
+
const landedReport = reading.sync;
|
|
221
|
+
if (reading.reportUnreadable !== undefined) {
|
|
222
|
+
warnings.push(`${reading.reportUnreadable} — the join landed, but what it dropped or quarantined cannot be reported from this round`);
|
|
223
|
+
}
|
|
215
224
|
for (const d of landedReport?.droppedInbound ?? []) {
|
|
216
225
|
if (!dropped.some((x) => x.dot.actor === d.dot.actor && x.dot.counter === d.dot.counter && x.reason === d.reason))
|
|
217
226
|
dropped.push(d);
|