@sema-agent/core 5.21.0 → 5.22.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 +66 -0
- package/dist/agents/send-message-tool.js +6 -3
- package/dist/agents/subagent.d.ts +6 -0
- package/dist/agents/subagent.js +45 -4
- package/dist/brain/errors.d.ts +20 -0
- package/dist/brain/errors.js +40 -0
- package/dist/brain/retry.d.ts +16 -2
- package/dist/brain/retry.js +3 -2
- package/dist/brain/status-sink.d.ts +9 -2
- package/dist/brain/stream-engine.d.ts +22 -0
- package/dist/brain/stream-engine.js +41 -10
- package/dist/core/ask-class.d.ts +48 -0
- package/dist/core/ask-class.js +33 -0
- package/dist/core/checkpoint-store.d.ts +103 -10
- package/dist/core/checkpoint-store.js +3 -1
- package/dist/core/governance-codes.d.ts +38 -0
- package/dist/core/governance-codes.js +11 -0
- package/dist/core/hooks.d.ts +39 -0
- package/dist/core/hooks.js +26 -2
- package/dist/core/locked-config.d.ts +7 -1
- package/dist/core/locked-config.js +2 -1
- package/dist/core/memory-engine/delegation-provenance.d.ts +62 -0
- package/dist/core/memory-engine/delegation-provenance.js +26 -0
- package/dist/core/memory-engine/engine.d.ts +67 -1
- package/dist/core/memory-engine/engine.js +270 -12
- package/dist/core/memory-engine/header-hints.d.ts +30 -0
- package/dist/core/memory-engine/header-hints.js +41 -0
- package/dist/core/memory-engine/index.d.ts +3 -2
- package/dist/core/memory-engine/index.js +3 -2
- package/dist/core/memory-engine/layout.d.ts +166 -0
- package/dist/core/memory-engine/layout.js +399 -0
- package/dist/core/memory-engine/tools.d.ts +30 -0
- package/dist/core/memory-engine/tools.js +108 -17
- package/dist/core/permission-rule-consent.d.ts +25 -9
- package/dist/core/permission-rule-consent.js +91 -20
- package/dist/core/permission-rule-model.d.ts +9 -1
- package/dist/core/permission-rule-model.js +2 -2
- package/dist/core/permission-rule-org.d.ts +161 -0
- package/dist/core/permission-rule-org.js +211 -0
- package/dist/core/permission-rule-store.d.ts +249 -6
- package/dist/core/permission-rule-store.js +313 -3
- package/dist/core/permission-rule-sync.d.ts +131 -0
- package/dist/core/permission-rule-sync.js +314 -0
- package/dist/core/runner/prepare-memory.js +35 -8
- package/dist/core/runner/prepare-task.d.ts +54 -1
- package/dist/core/runner/prepare-task.js +246 -27
- package/dist/core/runner/runtask.js +147 -6
- package/dist/core/shared-memory/contract.js +19 -4
- package/dist/core/shared-memory/normalize.d.ts +3 -1
- package/dist/core/shared-memory/tools.js +73 -17
- package/dist/core/shared-memory/types.d.ts +27 -1
- package/dist/core/store-contracts/permission-rule-sync-contract.d.ts +33 -0
- package/dist/core/store-contracts/permission-rule-sync-contract.js +186 -0
- package/dist/core/task-notification.d.ts +5 -2
- package/dist/core/task-registry-agent.d.ts +1 -1
- package/dist/core/task-registry-agent.js +6 -2
- package/dist/core/task-registry-shared.d.ts +9 -2
- package/dist/core/task-registry.d.ts +9 -3
- package/dist/core/task-registry.js +2 -0
- package/dist/core/tool-policy.d.ts +120 -2
- package/dist/core/tool-policy.js +116 -6
- package/dist/core/trace.d.ts +32 -1
- package/dist/core/types.d.ts +56 -3
- package/dist/index.d.ts +12 -7
- package/dist/index.js +10 -5
- package/dist/stores/file/checkpoint-store.d.ts +4 -0
- package/dist/stores/file/checkpoint-store.js +1 -0
- package/dist/stores/file/permission-rule-adopt.d.ts +62 -0
- package/dist/stores/file/permission-rule-adopt.js +95 -0
- package/dist/stores/file/permission-rule-store.d.ts +80 -2
- package/dist/stores/file/permission-rule-store.js +189 -46
- package/dist/tools/fs/fs-search-tools.js +0 -1
- package/package.json +1 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { parseAllowRuleText, ruleAdmitsCommand } from "./permission-rule-model.js";
|
|
2
|
+
import { sameScope, writerOf } from "./permission-rule-store.js";
|
|
3
|
+
export function orgRuleStatePersistenceOf(store) {
|
|
4
|
+
const s = store;
|
|
5
|
+
return typeof s.readOrgState === "function" && typeof s.installOrgState === "function"
|
|
6
|
+
? { readOrgState: s.readOrgState.bind(store), installOrgState: s.installOrgState.bind(store) }
|
|
7
|
+
: undefined;
|
|
8
|
+
}
|
|
9
|
+
export const ORG_UNAVAILABLE_DECISION_REASON = "org_unavailable";
|
|
10
|
+
export const ORG_FETCHED_AT_SKEW_ALLOWANCE_MS = 5 * 60_000;
|
|
11
|
+
export function createOrgRuleOverlay(cfg) {
|
|
12
|
+
if (cfg.governed !== true) {
|
|
13
|
+
throw new Error("createOrgRuleOverlay is only for org-governed deployments — pass `governed: true` or do not construct one");
|
|
14
|
+
}
|
|
15
|
+
if (cfg.provider === undefined) {
|
|
16
|
+
throw new Error("org-governed is declared but no OrgRuleSnapshotProvider is wired — a governed deployment without an org source is a configuration contradiction; refusing to boot rather than running silently ungoverned");
|
|
17
|
+
}
|
|
18
|
+
if (typeof cfg.stalenessBoundMs !== "number" || !Number.isFinite(cfg.stalenessBoundMs) || cfg.stalenessBoundMs <= 0) {
|
|
19
|
+
throw new Error(`org stalenessBoundMs must be a finite positive number of milliseconds (got ${String(cfg.stalenessBoundMs)})`);
|
|
20
|
+
}
|
|
21
|
+
const provider = cfg.provider;
|
|
22
|
+
const now = cfg.now ?? Date.now;
|
|
23
|
+
let memoryState;
|
|
24
|
+
const readState = async () => {
|
|
25
|
+
const state = cfg.persistence !== undefined ? await cfg.persistence.readOrgState() : memoryState;
|
|
26
|
+
return state === undefined ? undefined : structuredClone(state);
|
|
27
|
+
};
|
|
28
|
+
const writeState = async (state) => {
|
|
29
|
+
if (cfg.persistence !== undefined)
|
|
30
|
+
await cfg.persistence.installOrgState(structuredClone(state));
|
|
31
|
+
else
|
|
32
|
+
memoryState = structuredClone(state);
|
|
33
|
+
};
|
|
34
|
+
let chain = Promise.resolve();
|
|
35
|
+
const serialize = (fn) => {
|
|
36
|
+
const run = chain.then(fn, fn);
|
|
37
|
+
chain = run.then(() => undefined, () => undefined);
|
|
38
|
+
return run;
|
|
39
|
+
};
|
|
40
|
+
const resolve = async () => {
|
|
41
|
+
const disclosures = [];
|
|
42
|
+
if (cfg.persistence === undefined) {
|
|
43
|
+
disclosures.push("org rule state has no durable persistence wired — the last-known-good snapshot and the anti-rollback mark do not survive a restart");
|
|
44
|
+
}
|
|
45
|
+
let fetched;
|
|
46
|
+
let fetchFailed = false;
|
|
47
|
+
try {
|
|
48
|
+
fetched = await provider.current();
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
fetchFailed = true;
|
|
52
|
+
disclosures.push(`org snapshot provider failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
53
|
+
}
|
|
54
|
+
return await serialize(async () => {
|
|
55
|
+
const nowMs = now();
|
|
56
|
+
const guardedRead = async (context) => {
|
|
57
|
+
try {
|
|
58
|
+
return await readState();
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
disclosures.push(`org state ${context} read failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const installed = await (async () => {
|
|
66
|
+
const persisted = await guardedRead("persistence");
|
|
67
|
+
if (fetched === undefined)
|
|
68
|
+
return persisted;
|
|
69
|
+
const invalid = validateOrgSnapshot(fetched, nowMs);
|
|
70
|
+
if (invalid !== undefined) {
|
|
71
|
+
disclosures.push(`org snapshot refused: ${invalid}; the previously installed snapshot (if any) remains in force`);
|
|
72
|
+
return persisted;
|
|
73
|
+
}
|
|
74
|
+
if (persisted !== undefined && fetched.revision < persisted.revisionHighWater) {
|
|
75
|
+
disclosures.push(`org snapshot revision ${fetched.revision} is below the installed high-water mark ${persisted.revisionHighWater} — refusing the rollback; the current deny set is unchanged`);
|
|
76
|
+
return persisted;
|
|
77
|
+
}
|
|
78
|
+
if (persisted !== undefined && fetched.revision === persisted.snapshot.revision && !sameOrgPolicyContent(fetched, persisted.snapshot)) {
|
|
79
|
+
disclosures.push(`org snapshot revision ${fetched.revision} equals the installed revision but carries DIFFERENT policy content — refusing the swap; the current deny set is unchanged`);
|
|
80
|
+
return persisted;
|
|
81
|
+
}
|
|
82
|
+
const state = {
|
|
83
|
+
revisionHighWater: Math.max(persisted?.revisionHighWater ?? 0, fetched.revision),
|
|
84
|
+
snapshot: fetched,
|
|
85
|
+
installedAtMs: nowMs,
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
await writeState(state);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
const reread = await guardedRead("reconciliation");
|
|
92
|
+
if (reread !== undefined && reread.revisionHighWater >= state.revisionHighWater) {
|
|
93
|
+
disclosures.push(`org snapshot install was superseded by a concurrent install at revision ${reread.snapshot.revision} — continuing on the newer installed state`);
|
|
94
|
+
return reread;
|
|
95
|
+
}
|
|
96
|
+
disclosures.push(`org snapshot install failed (${err instanceof Error ? err.message : String(err)}) and no superseding installed state exists — org adjudication is UNAVAILABLE`);
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return state;
|
|
100
|
+
})();
|
|
101
|
+
const decisionMs = now();
|
|
102
|
+
const withinBound = (snap) => decisionMs - snap.fetchedAtMs <= cfg.stalenessBoundMs && snap.fetchedAtMs <= decisionMs + ORG_FETCHED_AT_SKEW_ALLOWANCE_MS;
|
|
103
|
+
if (installed !== undefined) {
|
|
104
|
+
if (installed.snapshot.fetchedAtMs > decisionMs + ORG_FETCHED_AT_SKEW_ALLOWANCE_MS) {
|
|
105
|
+
disclosures.push(`the installed org snapshot claims a FUTURE observation time (${installed.snapshot.fetchedAtMs} vs now ${decisionMs}) — org adjudication is UNAVAILABLE; the consuming gate must tighten every terminal allow to a real-approval ask`);
|
|
106
|
+
return { status: "unavailable", rules: [], disclosures };
|
|
107
|
+
}
|
|
108
|
+
if (withinBound(installed.snapshot)) {
|
|
109
|
+
const freshlyInstalled = fetched !== undefined && installed.snapshot === fetched;
|
|
110
|
+
if (!freshlyInstalled && !fetchFailed && fetched === undefined) {
|
|
111
|
+
disclosures.push("org snapshot provider returned no snapshot — continuing on the last-known-good");
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
status: freshlyInstalled ? "fresh" : "last-known-good",
|
|
115
|
+
rules: structuredClone(installed.snapshot.rules),
|
|
116
|
+
revision: installed.snapshot.revision,
|
|
117
|
+
disclosures,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
disclosures.push(`the newest org snapshot was observed ${decisionMs - installed.snapshot.fetchedAtMs}ms ago (bound ${cfg.stalenessBoundMs}ms) — org adjudication is UNAVAILABLE; the consuming gate must tighten every terminal allow to a real-approval ask`);
|
|
121
|
+
return { status: "unavailable", rules: [], disclosures };
|
|
122
|
+
}
|
|
123
|
+
disclosures.push("no org snapshot has ever been installed — org adjudication is UNAVAILABLE; the consuming gate must tighten every terminal allow to a real-approval ask");
|
|
124
|
+
return { status: "unavailable", rules: [], disclosures };
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
return { resolve };
|
|
128
|
+
}
|
|
129
|
+
function sameOrgPolicyContent(a, b) {
|
|
130
|
+
if (a.rules.length !== b.rules.length)
|
|
131
|
+
return false;
|
|
132
|
+
return a.rules.every((r, i) => b.rules[i]?.rule === r.rule && b.rules[i]?.behavior === r.behavior);
|
|
133
|
+
}
|
|
134
|
+
function validateOrgSnapshot(s, nowMs) {
|
|
135
|
+
if (typeof s.revision !== "number" || !Number.isFinite(s.revision))
|
|
136
|
+
return `revision is not a finite number (${String(s.revision)})`;
|
|
137
|
+
if (typeof s.fetchedAtMs !== "number" || !Number.isFinite(s.fetchedAtMs))
|
|
138
|
+
return `fetchedAtMs is not a finite number (${String(s.fetchedAtMs)})`;
|
|
139
|
+
if (s.fetchedAtMs > nowMs + ORG_FETCHED_AT_SKEW_ALLOWANCE_MS) {
|
|
140
|
+
return `fetchedAtMs is ${s.fetchedAtMs - nowMs}ms in the future (allowance ${ORG_FETCHED_AT_SKEW_ALLOWANCE_MS}ms) — a future-dated observation would satisfy the staleness bound indefinitely`;
|
|
141
|
+
}
|
|
142
|
+
if (!Array.isArray(s.rules))
|
|
143
|
+
return "rules is not an array";
|
|
144
|
+
for (const r of s.rules) {
|
|
145
|
+
if (typeof r?.rule !== "string" || r.rule === "")
|
|
146
|
+
return "a rule entry carries no rule text";
|
|
147
|
+
if (r.behavior !== "deny" && r.behavior !== "ask")
|
|
148
|
+
return `rule "${r.rule}" carries behavior "${String(r.behavior)}" — the org layer has no allow bucket`;
|
|
149
|
+
const parsed = parseAllowRuleText(r.rule, { direction: "tighten" });
|
|
150
|
+
if ("reject" in parsed)
|
|
151
|
+
return `rule "${r.rule}" does not parse (${parsed.reject.code}) — an unenforceable ${r.behavior} must not install as policy`;
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
export function orgRuleVerdictFor(rules, call) {
|
|
156
|
+
let ask;
|
|
157
|
+
for (const r of rules) {
|
|
158
|
+
const parsed = parseAllowRuleText(r.rule, { direction: "tighten" });
|
|
159
|
+
if ("reject" in parsed)
|
|
160
|
+
continue;
|
|
161
|
+
if (parsed.rule.tool !== call.tool)
|
|
162
|
+
continue;
|
|
163
|
+
if (!ruleAdmitsCommand(parsed.rule, call.command))
|
|
164
|
+
continue;
|
|
165
|
+
if (r.behavior === "deny")
|
|
166
|
+
return { behavior: "deny", rule: r.rule };
|
|
167
|
+
ask ??= { behavior: "ask", rule: r.rule };
|
|
168
|
+
}
|
|
169
|
+
return ask;
|
|
170
|
+
}
|
|
171
|
+
export async function effectivePermissionRules(opts) {
|
|
172
|
+
const store = resolveIntrospectionStore(opts);
|
|
173
|
+
const listed = await store.list();
|
|
174
|
+
const orgDenies = (opts.orgSnapshot?.rules ?? []).filter((r) => r.behavior === "deny");
|
|
175
|
+
const out = [];
|
|
176
|
+
for (const r of listed.rules) {
|
|
177
|
+
const shadowed = orgDenies.some((d) => {
|
|
178
|
+
const parsed = parseAllowRuleText(d.rule, { direction: "tighten" });
|
|
179
|
+
return !("reject" in parsed) && parsed.rule.tool === r.tool && ruleAdmitsCommand(parsed.rule, r.command);
|
|
180
|
+
});
|
|
181
|
+
out.push({ rule: r.rule, scope: r.scope, status: shadowed ? "shadowed-by-org" : "live" });
|
|
182
|
+
}
|
|
183
|
+
for (const t of listed.tombstones) {
|
|
184
|
+
if (listed.rules.some((r) => r.rule === t.rule && sameScope(r.scope, t.scope)))
|
|
185
|
+
continue;
|
|
186
|
+
if (out.some((e) => e.rule === t.rule && sameScope(e.scope, t.scope)))
|
|
187
|
+
continue;
|
|
188
|
+
out.push({ rule: t.rule, scope: t.scope, status: "removed" });
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
function resolveIntrospectionStore(opts) {
|
|
193
|
+
if (opts.owner !== undefined && opts.owner.kind === "local-owner") {
|
|
194
|
+
if (opts.principal !== undefined) {
|
|
195
|
+
throw new Error("pass either a principal or a local-owner, not both — a bucket has one owner");
|
|
196
|
+
}
|
|
197
|
+
const store = opts.provider.forLocalOwner?.();
|
|
198
|
+
if (store === undefined) {
|
|
199
|
+
throw new Error("this provider has no local-owner bucket (forLocalOwner is not implemented)");
|
|
200
|
+
}
|
|
201
|
+
return store;
|
|
202
|
+
}
|
|
203
|
+
if (opts.owner !== undefined && opts.owner.kind === "principal" && opts.principal !== undefined && opts.owner.principal !== opts.principal) {
|
|
204
|
+
throw new Error(`contradictory identity: principal "${opts.principal}" and owner principal "${opts.owner.principal}" disagree`);
|
|
205
|
+
}
|
|
206
|
+
const principal = opts.owner?.kind === "principal" ? opts.owner.principal : opts.principal;
|
|
207
|
+
return opts.provider.forPrincipal(principal);
|
|
208
|
+
}
|
|
209
|
+
export function isWritablePermissionRuleStore(store) {
|
|
210
|
+
return writerOf(store) !== undefined;
|
|
211
|
+
}
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* person pressing delete a second time.
|
|
32
32
|
*/
|
|
33
33
|
import type { PersistedAllowRule, RuleAdd, RuleDot, RuleScope, RuleTombstone } from "./permission-rule-model.js";
|
|
34
|
+
import type { RuleSyncDropReason, RuleQuarantineReason } from "./governance-codes.js";
|
|
34
35
|
import type { StoreDurability, StoreFidelity } from "./checkpoint-store.js";
|
|
35
36
|
/** One store read: the rules as persisted, their tombstones, and the revision they were read at. */
|
|
36
37
|
export interface StoredAllowRules {
|
|
@@ -51,24 +52,59 @@ export interface StoredAllowRules {
|
|
|
51
52
|
*/
|
|
52
53
|
export interface PermissionRuleStore {
|
|
53
54
|
list(): Promise<StoredAllowRules>;
|
|
55
|
+
/**
|
|
56
|
+
* design/182 §5.2/§8.3 — the quarantine area, for introspection. Rows a sync round moved out of the
|
|
57
|
+
* live view (fence arm, server rejection, validator rejection): bytes kept, never silently deleted,
|
|
58
|
+
* re-enterable only through a new consent. Optional and additive — a backend without sync has none.
|
|
59
|
+
*/
|
|
60
|
+
quarantined?(): Promise<QuarantinedRuleAdd[]>;
|
|
54
61
|
/** Declared durability; an undeclared backend reads fail-closed as `"process-local"`. */
|
|
55
62
|
readonly durability?: StoreDurability;
|
|
56
63
|
/** Declared serialization fidelity, same doctrine as the other store families. */
|
|
57
64
|
readonly fidelity?: StoreFidelity;
|
|
58
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* design/182 §3.3/§4.5 — who a rule bucket belongs to, as a STRUCTURAL position.
|
|
68
|
+
*
|
|
69
|
+
* Identity absence is spelled as a distinct union member, never as a sentinel string flowing through
|
|
70
|
+
* `forPrincipal` — every consumer of a principal string is an identity surface, and a made-up name on an
|
|
71
|
+
* identity surface is indistinguishable from a real one downstream. Cloud sync and cloud tickets accept
|
|
72
|
+
* ONLY the `principal` kind: syncing is an authenticated act, and a local-owner bucket has no cloud home
|
|
73
|
+
* until it is adopted (`adoptFilePermissionRuleStore`).
|
|
74
|
+
*/
|
|
75
|
+
export type RuleOwner = {
|
|
76
|
+
kind: "principal";
|
|
77
|
+
principal: string;
|
|
78
|
+
} | {
|
|
79
|
+
kind: "local-owner";
|
|
80
|
+
};
|
|
81
|
+
/** Do two owners name the same bucket? */
|
|
82
|
+
export declare function sameRuleOwner(a: RuleOwner, b: RuleOwner): boolean;
|
|
59
83
|
/** Resolves a store for one verified principal. `undefined` ⇒ a store that reports zero rules. */
|
|
60
84
|
export interface PermissionRuleStoreProvider {
|
|
61
85
|
forPrincipal(principal: string | undefined): PermissionRuleStore;
|
|
86
|
+
/**
|
|
87
|
+
* design/182 §4.5 (F-011) — the identity-less LOCAL bucket, for a deployment that explicitly declared
|
|
88
|
+
* local-owner rules. Optional and additive: a provider without it simply has no local-owner form.
|
|
89
|
+
* A file backend resolves a FIXED file name (never a principal-hash path), and after an adoption
|
|
90
|
+
* completed it resolves the ADOPTED principal's bucket forever — the retired bucket never revives.
|
|
91
|
+
*/
|
|
92
|
+
forLocalOwner?(): PermissionRuleStore;
|
|
62
93
|
}
|
|
63
|
-
/** The outcome of one accepted write.
|
|
94
|
+
/** The outcome of one accepted write. `sync` is present only on a `sync-join` delta — the landing
|
|
95
|
+
* report the disclosure layer reads (design/182 §8.1: quarantine/fence details ride the PutResult). */
|
|
64
96
|
export interface PutResult {
|
|
65
97
|
rev: number;
|
|
98
|
+
sync?: RuleSyncLandingReport;
|
|
66
99
|
}
|
|
67
100
|
/** Authorization accompanying an add: the redemption that produced it. Carrying the record id makes the
|
|
68
|
-
* add's logical operation identity checkable at the backend, not just at the caller.
|
|
101
|
+
* add's logical operation identity checkable at the backend, not just at the caller. A v1 caller
|
|
102
|
+
* carries `principal`; the local-owner path (design/182 §4.5) carries `owner` instead — never both
|
|
103
|
+
* disagreeing, never a sentinel string. */
|
|
69
104
|
export interface RedemptionAuthorization {
|
|
70
105
|
recordId: string;
|
|
71
|
-
principal
|
|
106
|
+
principal?: string;
|
|
107
|
+
owner?: RuleOwner;
|
|
72
108
|
}
|
|
73
109
|
/** The add half of the write union: one redeemed approval becoming one dot on one (rule, scope). */
|
|
74
110
|
export interface RuleAddDelta {
|
|
@@ -86,13 +122,46 @@ export interface RuleDeleteDelta {
|
|
|
86
122
|
kind: "tighten-delete";
|
|
87
123
|
tombstone: RuleTombstone;
|
|
88
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* design/182 §8.1 — one whole sync round landing as ONE delta.
|
|
127
|
+
*
|
|
128
|
+
* "Partial landing" is unspellable at the write layer: either the whole join lands or none of it does,
|
|
129
|
+
* and replaying the same join is idempotent. The delta cannot express "remove a local row" through the
|
|
130
|
+
* join (a join only ever unions) — that is what the explicit `quarantine` member is for, so a
|
|
131
|
+
* server-rejected or validator-rejected LOCAL row moves out of the live view in the SAME optimistic
|
|
132
|
+
* write as the join, never silently and never in a second racing write.
|
|
133
|
+
*/
|
|
134
|
+
export interface RuleSyncJoinDelta {
|
|
135
|
+
kind: "sync-join";
|
|
136
|
+
/** The validated inbound state (the client's §4.3 partitioning already applied). */
|
|
137
|
+
inbound: RuleSyncState;
|
|
138
|
+
/** The server's collected (already-recycled) frontier. Absent in v2.0 — the fence arms stay dormant. */
|
|
139
|
+
gcFrontier?: RuleSyncFrontier;
|
|
140
|
+
/** The observation vector to persist on a CLEAN landing (absent ⇒ the vector does not advance). */
|
|
141
|
+
observedVector?: RuleSyncFrontier;
|
|
142
|
+
/**
|
|
143
|
+
* Explicit local-row quarantine instructions. Each dot must name an add present in the current raw
|
|
144
|
+
* state (an already-quarantined or unknown dot is an idempotent no-op); an instruction may never touch
|
|
145
|
+
* a tombstone. Quarantined rows keep their bytes, leave the live view and the sync state, and are
|
|
146
|
+
* disclosed — they can re-enter only through a NEW consent (a new ask, a new record, a new dot).
|
|
147
|
+
*/
|
|
148
|
+
quarantine?: Array<{
|
|
149
|
+
rule: string;
|
|
150
|
+
scope: RuleScope;
|
|
151
|
+
dots: RuleDot[];
|
|
152
|
+
reason: RuleQuarantineReason;
|
|
153
|
+
}>;
|
|
154
|
+
}
|
|
89
155
|
/**
|
|
90
156
|
* What a write may say. Authorization discriminates on the DELTA SHAPE, not on a full snapshot: only the
|
|
91
157
|
* add arm can introduce a dot, and the delete arm carries a tombstone and no adds. A backend additionally
|
|
92
158
|
* REFUSES at runtime any delete that would introduce a new add dot — structure and runtime check together,
|
|
93
|
-
* so "pick the delete arm and smuggle an add" is neither expressible nor accepted.
|
|
159
|
+
* so "pick the delete arm and smuggle an add" is neither expressible nor accepted. The `sync-join` arm is
|
|
160
|
+
* constructible only by core (the writer is never exported), and every inbound record inside it passes the
|
|
161
|
+
* single validator again AT THE BACKEND — the fifth door of design/179 §4's validator list closes here,
|
|
162
|
+
* not at the calling layer.
|
|
94
163
|
*/
|
|
95
|
-
export type RuleWriteDelta = RuleAddDelta | RuleDeleteDelta;
|
|
164
|
+
export type RuleWriteDelta = RuleAddDelta | RuleDeleteDelta | RuleSyncJoinDelta;
|
|
96
165
|
/**
|
|
97
166
|
* The core-private write face. Deliberately absent from the package's public exports: a host cannot hold
|
|
98
167
|
* one, so no API-level path to the store bypasses the consent protocol.
|
|
@@ -107,6 +176,26 @@ export interface PermissionRuleWriter {
|
|
|
107
176
|
conflict: true;
|
|
108
177
|
rev: number;
|
|
109
178
|
}>;
|
|
179
|
+
/**
|
|
180
|
+
* design/182 §4.2 — the RAW state a sync round exchanges: adds with tombstoned dots NOT pre-filtered
|
|
181
|
+
* (list() filters; a join must not), plus the replica identity, the minted-counter high water and the
|
|
182
|
+
* persisted observation vector. Core-private like the rest of the writer: the raw view rides the sync
|
|
183
|
+
* client, never a host API.
|
|
184
|
+
*/
|
|
185
|
+
readRaw(): Promise<RawRuleSyncState>;
|
|
186
|
+
}
|
|
187
|
+
/** The writer's raw view of one store — everything one sync round needs to know about this replica. */
|
|
188
|
+
export interface RawRuleSyncState {
|
|
189
|
+
/** This replica's actor id — the wire `replica` field. A replica identity, NEVER a person identity. */
|
|
190
|
+
actor: string;
|
|
191
|
+
/** The highest counter this replica has minted — the own-actor forgery fence's local truth. */
|
|
192
|
+
counter: number;
|
|
193
|
+
rev: number;
|
|
194
|
+
rules: PersistedAllowRule[];
|
|
195
|
+
tombstones: RuleTombstone[];
|
|
196
|
+
/** The persisted observation vector (absent = never completed a clean round). */
|
|
197
|
+
observedVector?: RuleSyncFrontier;
|
|
198
|
+
quarantined?: QuarantinedRuleAdd[];
|
|
110
199
|
}
|
|
111
200
|
/**
|
|
112
201
|
* The internal handle a writable backend hangs its write face on. Not exported from the package index —
|
|
@@ -142,6 +231,156 @@ export declare function addDotsOf(rules: readonly PersistedAllowRule[]): RuleDot
|
|
|
142
231
|
* Every backend calls this before appending a tombstone.
|
|
143
232
|
*/
|
|
144
233
|
export declare function assertDeleteDeltaCarriesNoAdd(delta: RuleDeleteDelta): void;
|
|
234
|
+
/**
|
|
235
|
+
* The state one sync round exchanges: raw adds (tombstoned dots NOT pre-filtered) plus tombstones.
|
|
236
|
+
* The live view is always DERIVED (`applyTombstones`); the join below never deletes anything.
|
|
237
|
+
* The server-side twin MUST use the same join (consume the export, or pass
|
|
238
|
+
* `permissionRuleSyncContract` against its own reimplementation).
|
|
239
|
+
*/
|
|
240
|
+
export interface RuleSyncState {
|
|
241
|
+
rules: PersistedAllowRule[];
|
|
242
|
+
tombstones: RuleTombstone[];
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* An actor → counter vector. A dot is at-or-below a frontier ⟺ `dot.counter <= frontier[dot.actor]`.
|
|
246
|
+
* Used both as the client's observation vector and as the server's collected-frontier watermark —
|
|
247
|
+
* two quantities with strictly separated jobs (§5.1): only the COLLECTED watermark (monotone,
|
|
248
|
+
* never lowered by a registering or returning replica) may drive the fence arm.
|
|
249
|
+
*
|
|
250
|
+
* SQL twin note (server half): persist this as a JSON blob column — the vector is read and written
|
|
251
|
+
* whole, never queried per-actor; a row-per-actor table would invite partial updates the semantics
|
|
252
|
+
* cannot express. Epoch-millisecond columns around it follow the `_ms` suffix convention; the OCC
|
|
253
|
+
* counter column is named `rev` (never `version` — that word is reserved for format/schema versions).
|
|
254
|
+
*/
|
|
255
|
+
export type RuleSyncFrontier = Record<string, number>;
|
|
256
|
+
/** One quarantined row: bytes preserved, out of the live view and out of sync, disclosed.
|
|
257
|
+
* SQL twin note: `add` (dot + provenance) is one immutable value minted at approval time — persist it
|
|
258
|
+
* as a JSON blob; `rule`/`scope`/`reason`/`atMs` are query axes and belong in structured columns
|
|
259
|
+
* (`at_ms` on the SQL side). */
|
|
260
|
+
export interface QuarantinedRuleAdd {
|
|
261
|
+
rule: string;
|
|
262
|
+
scope: RuleScope;
|
|
263
|
+
add: RuleAdd;
|
|
264
|
+
reason: RuleQuarantineReason;
|
|
265
|
+
atMs: number;
|
|
266
|
+
}
|
|
267
|
+
/** One dropped/quarantined disclosure line. `reason` is a closed, typed code (RULE_SYNC_DROP_CODES in
|
|
268
|
+
* governance-codes.ts) — "silently dropped" must be machine-checkable, so free text is not a member. */
|
|
269
|
+
export interface RuleSyncDrop {
|
|
270
|
+
rule: string;
|
|
271
|
+
scope: RuleScope;
|
|
272
|
+
dot: RuleDot;
|
|
273
|
+
reason: RuleSyncDropReason;
|
|
274
|
+
}
|
|
275
|
+
/** What one landed sync-join reports back for disclosure (rides {@link PutResult}). */
|
|
276
|
+
export interface RuleSyncLandingReport {
|
|
277
|
+
/** Inbound records the backend refused (the peer still holds them — no data loss on this side). */
|
|
278
|
+
droppedInbound: RuleSyncDrop[];
|
|
279
|
+
/** Local rows moved into the quarantine area by this landing (instruction, fence, or validator). */
|
|
280
|
+
quarantined: RuleSyncDrop[];
|
|
281
|
+
/** Tombstones (with their covered adds) recycled below the frontier. Hygiene, not semantics. */
|
|
282
|
+
collectedTombstones: number;
|
|
283
|
+
/** Inbound adds withheld this round because an inbound tombstone was rejected (§4.3 tighten-only:
|
|
284
|
+
* a dropped tombstone may have covered them — they re-arrive once the peer's state is clean). */
|
|
285
|
+
withheldAdds: number;
|
|
286
|
+
}
|
|
287
|
+
/** Is `dot` at or below the frontier? Absent actor ⇒ not below (a frontier never covers what it never saw). */
|
|
288
|
+
export declare function dotAtOrBelowFrontier(dot: RuleDot, frontier: RuleSyncFrontier): boolean;
|
|
289
|
+
/**
|
|
290
|
+
* design/182 §5.1 — the state vector: per actor, the max counter over every add dot, every tombstone's
|
|
291
|
+
* `deletedBy` and every tombstone's `removedDots`. "How far has this state seen that actor mint."
|
|
292
|
+
*/
|
|
293
|
+
export declare function ruleSyncVector(state: RuleSyncState): RuleSyncFrontier;
|
|
294
|
+
/** Pointwise max of two frontiers — how an observation vector advances (monotone, never down). */
|
|
295
|
+
export declare function joinFrontiers(a: RuleSyncFrontier, b: RuleSyncFrontier): RuleSyncFrontier;
|
|
296
|
+
/**
|
|
297
|
+
* design/182 §3.1 pre-normalization — re-project the denormalized `tool`/`match`/`command` fields of one
|
|
298
|
+
* persisted rule from its `rule` TEXT (the single source of truth) through the one shared validator.
|
|
299
|
+
*
|
|
300
|
+
* A record whose text the validator refuses, or whose stored fields disagree with the re-projection, is
|
|
301
|
+
* damage or forgery — it is REFUSED (fail-closed), never repaired toward either side: picking a side
|
|
302
|
+
* would break the join's commutativity, and "take the wider" would widen a matching surface nobody
|
|
303
|
+
* approved. Returns the normalized record or the refusal reason.
|
|
304
|
+
*/
|
|
305
|
+
export declare function normalizePersistedRule(r: PersistedAllowRule): {
|
|
306
|
+
rule: PersistedAllowRule;
|
|
307
|
+
} | {
|
|
308
|
+
reject: Extract<RuleSyncDropReason, "invalid_rule_text" | "metadata_mismatch">;
|
|
309
|
+
};
|
|
310
|
+
/** The normalization half exposed for the DISCLOSURE layer: which records a join would refuse, and why. */
|
|
311
|
+
export declare function screenRuleSyncState(state: RuleSyncState): {
|
|
312
|
+
state: RuleSyncState;
|
|
313
|
+
rejected: RuleSyncDrop[];
|
|
314
|
+
};
|
|
315
|
+
/**
|
|
316
|
+
* design/182 §3.1 — the pure state join. Idempotent, commutative, associative; both sides monotone
|
|
317
|
+
* (a join only ever unions, it never deletes). The output is CANONICALLY ORDERED so equal states are
|
|
318
|
+
* deep-equal, which is what lets the contract suite assert the three laws structurally.
|
|
319
|
+
*
|
|
320
|
+
* - rules: grouped by (rule, scope); a group's adds are the dot-identity union (provenance rides each
|
|
321
|
+
* add; two payloads under ONE dot — unreachable in a healthy protocol — resolve by canonical-json
|
|
322
|
+
* byte order, determinism only, detection belongs to the screening layer);
|
|
323
|
+
* - tombstones: grouped by (rule, scope, deletedBy); same identity with differing `removedDots` takes
|
|
324
|
+
* the UNION (any one-sided pick breaks commutativity and can drop an observed removal — the union is
|
|
325
|
+
* the only convergent answer, and it only ever tightens);
|
|
326
|
+
* - both inputs pass {@link screenRuleSyncState} first (refused records are dropped deterministically;
|
|
327
|
+
* callers wanting the refusal DETAILS run the screen themselves — this function stays a pure join).
|
|
328
|
+
*/
|
|
329
|
+
export declare function joinRuleStates(a: RuleSyncState, b: RuleSyncState): RuleSyncState;
|
|
330
|
+
/**
|
|
331
|
+
* design/182 §5.1/§5.2 — the paired-collection arm: a tombstone is collectable ⟺ its `removedDots` are
|
|
332
|
+
* ALL at-or-below the frontier AND its `deletedBy` is too; collection removes the tombstone together
|
|
333
|
+
* with the adds it covers, atomically (collect only the tombstone and the next join resurrects the add;
|
|
334
|
+
* collect only the add and the leftover tombstone is idle but harmless — so it is always the pair).
|
|
335
|
+
*/
|
|
336
|
+
export declare function collectBelowFrontier(state: RuleSyncState, frontier: RuleSyncFrontier): {
|
|
337
|
+
state: RuleSyncState;
|
|
338
|
+
collectedTombstones: number;
|
|
339
|
+
};
|
|
340
|
+
/** The current raw facts a sync-join folds into — what {@link applySyncJoin} takes and returns. */
|
|
341
|
+
export interface RuleSyncJoinBase {
|
|
342
|
+
actor: string;
|
|
343
|
+
counter: number;
|
|
344
|
+
rules: PersistedAllowRule[];
|
|
345
|
+
tombstones: RuleTombstone[];
|
|
346
|
+
quarantined: QuarantinedRuleAdd[];
|
|
347
|
+
observedVector?: RuleSyncFrontier;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* design/182 §8.1 — the ONE landing pipeline every backend shares (the `foldDelta` doctrine: the file
|
|
351
|
+
* and in-memory forms must not drift on what a sync-join MEANS). Pure; the backend persists the result
|
|
352
|
+
* in a single optimistic write. Steps:
|
|
353
|
+
*
|
|
354
|
+
* ① screen the inbound state at the backend (the single validator's fifth door closes HERE — the
|
|
355
|
+
* backend does not trust the calling layer): text/metadata refusals drop the inbound record;
|
|
356
|
+
* the own-actor forgery fence (M14) drops any inbound add or tombstone claiming THIS replica's
|
|
357
|
+
* actor with a counter above what this replica has minted; an inbound tombstone with an empty
|
|
358
|
+
* `removedDots` is a protocol violation and THROWS (the client's tighten-only partitioning must
|
|
359
|
+
* have withheld the round's adds — a backend landing them anyway would loosen);
|
|
360
|
+
* ② join (unions only, never deletes);
|
|
361
|
+
* ③ with a `gcFrontier`: paired collection, then the fence arm — a live LOCAL add at-or-below the
|
|
362
|
+
* collected frontier and ABSENT from the inbound state is the residue of an already-recycled
|
|
363
|
+
* tombstone: moved to quarantine (never silently dropped), disclosed;
|
|
364
|
+
* ④ execute the explicit quarantine instructions (idempotent per dot; touching a tombstone throws);
|
|
365
|
+
* ⑤ screen LOCAL rows too — a locally damaged row (file edit) moves to quarantine rather than
|
|
366
|
+
* riding every future round;
|
|
367
|
+
* ⑥ persist the observation vector the delta carries (the client only sends one on a clean round).
|
|
368
|
+
*/
|
|
369
|
+
export declare function applySyncJoin(cur: RuleSyncJoinBase, delta: RuleSyncJoinDelta, nowMs: number): {
|
|
370
|
+
next: RuleSyncJoinBase;
|
|
371
|
+
report: RuleSyncLandingReport;
|
|
372
|
+
};
|
|
373
|
+
/**
|
|
374
|
+
* The redemption-side quarantine fence, shared by every backend (design/182 §5.2 review round 2):
|
|
375
|
+
* a quarantined row's contract is "re-entry only through a NEW consent — a new ask, a new record, a
|
|
376
|
+
* NEW dot". Replaying an already-redeemed approval record re-submits its RECORDED dot; once that
|
|
377
|
+
* add has been quarantined, folding the replay back into `rules` would resurrect it without anyone
|
|
378
|
+
* consenting again (and for `below_gc_frontier`, resurrect an approval whose deletion tombstone was
|
|
379
|
+
* already recycled). Any quarantined row sharing the delta's dot — under ANY (rule, scope) — refuses
|
|
380
|
+
* the write loudly: dots are minted once, so a replayed dot that quarantine pinned anywhere is the
|
|
381
|
+
* resurrection shape regardless of which tuple it claims now.
|
|
382
|
+
*/
|
|
383
|
+
export declare function assertRedemptionNotQuarantined(quarantined: readonly QuarantinedRuleAdd[], delta: RuleAddDelta): void;
|
|
145
384
|
/** Integrity fingerprint over a store's own facts. Corruption detection, not tamper-proofing. */
|
|
146
385
|
export declare function ruleStoreChecksum(payload: unknown): Promise<string>;
|
|
147
386
|
/**
|
|
@@ -193,13 +432,17 @@ export declare const EMPTY_RULE_STORE: PermissionRuleStore;
|
|
|
193
432
|
*/
|
|
194
433
|
export declare class InMemoryPermissionRuleStore implements WritablePermissionRuleStore {
|
|
195
434
|
private readonly actor;
|
|
435
|
+
private readonly now;
|
|
196
436
|
readonly durability: StoreDurability;
|
|
197
437
|
readonly fidelity: StoreFidelity;
|
|
198
438
|
private rules;
|
|
199
439
|
private tombstones;
|
|
440
|
+
private quarantinedRows;
|
|
441
|
+
private observedVector;
|
|
200
442
|
private rev;
|
|
201
443
|
private counter;
|
|
202
|
-
constructor(actor?: string);
|
|
444
|
+
constructor(actor?: string, now?: () => number);
|
|
203
445
|
list(): Promise<StoredAllowRules>;
|
|
446
|
+
quarantined(): Promise<QuarantinedRuleAdd[]>;
|
|
204
447
|
readonly [PERMISSION_RULE_WRITER]: PermissionRuleWriter;
|
|
205
448
|
}
|