@githolon/dsl 0.105.24 → 0.107.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/dart/CHANGELOG.md +6 -0
- package/dart/lib/src/runner/node-entry.mjs +79 -2
- package/dart/lib/src/runner/offline-install.mjs +17 -7
- package/dart/pubspec.yaml +1 -1
- package/package.json +1 -1
- package/runtime/generated-application-host.js +79 -2
- package/src/aggregate.ts +21 -2
- package/src/capability_exports.ts +1 -1
- package/src/compile_package_main.ts +2 -2
- package/src/framework/capability.ts +41 -1
- package/src/framework/impure_capability.ts +195 -4
- package/src/framework/owned_custody.ts +4 -0
- package/src/framework/sync_lifecycle.ts +1 -0
package/dart/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## 0.64.40
|
|
2
|
+
|
|
3
|
+
- Clone the workspace ledger directly from Cloudflare Artifacts when the cloud grants a read lease
|
|
4
|
+
(`/git-read-lease`), falling back to the `/pack` lane; retry transient pack-transport failures. Large
|
|
5
|
+
ledgers no longer time out the boot clone.
|
|
6
|
+
|
|
1
7
|
## 0.64.39
|
|
2
8
|
|
|
3
9
|
- Resolve kernel compatibility through typed OSGi Contract Namespace requirements and OpenUSD candidate
|
|
@@ -7581,6 +7581,56 @@ async function lsRefs(remote, headers = {}, { service = "git-upload-pack", prefi
|
|
|
7581
7581
|
}
|
|
7582
7582
|
return refs;
|
|
7583
7583
|
}
|
|
7584
|
+
async function fetchPack(remote, headers = {}, { wants = [], haves = [] } = {}) {
|
|
7585
|
+
wants = wants.filter((w) => /^[0-9a-f]{40}$/.test(w) && w !== ZERO_OID);
|
|
7586
|
+
if (!wants.length) return null;
|
|
7587
|
+
const caps = "ofs-delta agent=nomos-cloud";
|
|
7588
|
+
const parts = [];
|
|
7589
|
+
wants.forEach((w, i) => parts.push(pkt(`want ${w}${i === 0 ? " " + caps : ""}
|
|
7590
|
+
`)));
|
|
7591
|
+
parts.push(FLUSH);
|
|
7592
|
+
for (const h of haves) if (/^[0-9a-f]{40}$/.test(h) && h !== ZERO_OID) parts.push(pkt(`have ${h}
|
|
7593
|
+
`));
|
|
7594
|
+
parts.push(pkt("done\n"));
|
|
7595
|
+
const body = concatBytes(parts);
|
|
7596
|
+
const reqHeaders = { "content-type": "application/x-git-upload-pack-request", "accept": "application/x-git-upload-pack-result", ...headers };
|
|
7597
|
+
const MAX_ATTEMPTS = 6;
|
|
7598
|
+
let buf;
|
|
7599
|
+
for (let attempt = 1; ; attempt++) {
|
|
7600
|
+
try {
|
|
7601
|
+
const r = await fetch(`${remote}/git-upload-pack`, { method: "POST", headers: reqHeaders, body, signal: AbortSignal.timeout(18e4) });
|
|
7602
|
+
if (!r.ok) throw new Error(`upload-pack ${r.status}: ${(await r.text()).slice(0, 180)}`);
|
|
7603
|
+
buf = new Uint8Array(await r.arrayBuffer());
|
|
7604
|
+
break;
|
|
7605
|
+
} catch (e) {
|
|
7606
|
+
const msg = String(e?.message || e);
|
|
7607
|
+
const transient = /terminated|other side closed|socket|ECONNRESET|ECONNREFUSED|UND_ERR|fetch failed|network|aborted|timeout/i.test(msg) && !/upload-pack \d{3}:/.test(msg);
|
|
7608
|
+
if (!transient || attempt >= MAX_ATTEMPTS) throw e;
|
|
7609
|
+
await new Promise((res) => setTimeout(res, Math.min(4e3, 400 * attempt)));
|
|
7610
|
+
}
|
|
7611
|
+
}
|
|
7612
|
+
const { lines, rest } = readPktLines(buf, 0);
|
|
7613
|
+
const packChunks = [];
|
|
7614
|
+
let sideband = false;
|
|
7615
|
+
for (const line of lines) {
|
|
7616
|
+
if (line.length === 0) continue;
|
|
7617
|
+
const head = dec3.decode(line.subarray(0, Math.min(line.length, 3)));
|
|
7618
|
+
if (head === "NAK" || head === "ACK" || head === "ERR" || head.startsWith("sha")) continue;
|
|
7619
|
+
const band = line[0];
|
|
7620
|
+
if (band === 1) {
|
|
7621
|
+
packChunks.push(line.subarray(1));
|
|
7622
|
+
sideband = true;
|
|
7623
|
+
} else if (band === 2) {
|
|
7624
|
+
} else if (band === 3) {
|
|
7625
|
+
throw new Error("upload-pack fatal: " + dec3.decode(line.subarray(1)).slice(0, 180));
|
|
7626
|
+
}
|
|
7627
|
+
}
|
|
7628
|
+
const pack = sideband ? concatBytes(packChunks) : buf.subarray(rest);
|
|
7629
|
+
if (pack.length < 12 || dec3.decode(pack.subarray(0, 4)) !== "PACK") {
|
|
7630
|
+
throw new Error(`upload-pack: no pack in response (${pack.length}B, sideband=${sideband})`);
|
|
7631
|
+
}
|
|
7632
|
+
return pack;
|
|
7633
|
+
}
|
|
7584
7634
|
async function pushPack(remote, headers = {}, { updates = [], pack = null } = {}, fetchImpl = fetch) {
|
|
7585
7635
|
if (!updates.length) return { ok: true, noop: true };
|
|
7586
7636
|
const body = concatBytes([receivePackCommandPrefix(updates), pack && pack.length ? pack : EMPTY_PACK]);
|
|
@@ -13005,6 +13055,33 @@ ${String(commit).toLowerCase()}`;
|
|
|
13005
13055
|
const addReadRemote = async () => {
|
|
13006
13056
|
};
|
|
13007
13057
|
const applyPack = (packB64, head) => provider.applyPack(packB64, head);
|
|
13058
|
+
const directFullClone = async (label) => {
|
|
13059
|
+
let lease = null;
|
|
13060
|
+
try {
|
|
13061
|
+
const r = await _fetch(`${base}/v2/workspaces/${workspace}/git-read-lease`, { headers: authHeaders });
|
|
13062
|
+
if (r.ok) {
|
|
13063
|
+
const d = await r.json();
|
|
13064
|
+
if (d && d.ok && typeof d.remote === "string" && typeof d.token === "string") lease = { remote: d.remote, token: d.token };
|
|
13065
|
+
}
|
|
13066
|
+
} catch {
|
|
13067
|
+
}
|
|
13068
|
+
if (!lease) return false;
|
|
13069
|
+
const _f = span("receive", `${label} (direct artifacts)`, "cfetch", {});
|
|
13070
|
+
try {
|
|
13071
|
+
const auth = { Authorization: "Basic " + btoa("x:" + lease.token) };
|
|
13072
|
+
const refs = await lsRefs(lease.remote, auth, { service: "git-upload-pack", prefix: "refs/heads/main" });
|
|
13073
|
+
const head = refs["refs/heads/main"];
|
|
13074
|
+
if (!head || !/^[0-9a-f]{40}$/i.test(head)) return false;
|
|
13075
|
+
const pack = await fetchPack(lease.remote, auth, { wants: [head] });
|
|
13076
|
+
if (!pack || !pack.byteLength) return false;
|
|
13077
|
+
applyPack(nmsInternalSnapshotToBase64(pack), head);
|
|
13078
|
+
return true;
|
|
13079
|
+
} catch {
|
|
13080
|
+
return false;
|
|
13081
|
+
} finally {
|
|
13082
|
+
_f.end();
|
|
13083
|
+
}
|
|
13084
|
+
};
|
|
13008
13085
|
try {
|
|
13009
13086
|
await provider.boot({ base, workspace, branch: BRANCH, repoArg, restoreSnapshot: restoreFrom || null, fetchImpl: _fetch, authHeaders });
|
|
13010
13087
|
} catch (error) {
|
|
@@ -13182,7 +13259,7 @@ ${String(commit).toLowerCase()}`;
|
|
|
13182
13259
|
_bootLedger.end();
|
|
13183
13260
|
if (!allowUnborn) throw new Error(`workspace '${workspace}' has no ledger main \u2014 create it first (POST /v2/workspaces/${workspace})`);
|
|
13184
13261
|
} else {
|
|
13185
|
-
await fetchAndApplyPack("", "boot: fetch full pack");
|
|
13262
|
+
if (!await directFullClone("boot: full pack")) await fetchAndApplyPack("", "boot: fetch full pack");
|
|
13186
13263
|
const mounted = await mountWithResidency(remoteMain, "boot restore");
|
|
13187
13264
|
if (mounted && typeof mounted === "object" && mounted.kernelSpans) {
|
|
13188
13265
|
bootKernelSpans = mounted.kernelSpans;
|
|
@@ -13717,7 +13794,7 @@ ${String(commit).toLowerCase()}`;
|
|
|
13717
13794
|
const forceFullMainHistory = async (why, _cause) => {
|
|
13718
13795
|
const _full = span("receive", `${why}: re-pull FULL main`, "cfetch");
|
|
13719
13796
|
try {
|
|
13720
|
-
await fetchAndApplyPack("", `${why}: full main pack`);
|
|
13797
|
+
if (!await directFullClone(`${why}: full main`)) await fetchAndApplyPack("", `${why}: full main pack`);
|
|
13721
13798
|
} finally {
|
|
13722
13799
|
_full.end();
|
|
13723
13800
|
}
|
|
@@ -6098,13 +6098,23 @@ async function fetchPack(remote, headers = {}, { wants = [], haves = [] } = {})
|
|
|
6098
6098
|
for (const h of haves) if (/^[0-9a-f]{40}$/.test(h) && h !== ZERO_OID) parts.push(pkt(`have ${h}
|
|
6099
6099
|
`));
|
|
6100
6100
|
parts.push(pkt("done\n"));
|
|
6101
|
-
const
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6101
|
+
const body = concatBytes(parts);
|
|
6102
|
+
const reqHeaders = { "content-type": "application/x-git-upload-pack-request", "accept": "application/x-git-upload-pack-result", ...headers };
|
|
6103
|
+
const MAX_ATTEMPTS = 6;
|
|
6104
|
+
let buf;
|
|
6105
|
+
for (let attempt = 1; ; attempt++) {
|
|
6106
|
+
try {
|
|
6107
|
+
const r = await fetch(`${remote}/git-upload-pack`, { method: "POST", headers: reqHeaders, body, signal: AbortSignal.timeout(18e4) });
|
|
6108
|
+
if (!r.ok) throw new Error(`upload-pack ${r.status}: ${(await r.text()).slice(0, 180)}`);
|
|
6109
|
+
buf = new Uint8Array(await r.arrayBuffer());
|
|
6110
|
+
break;
|
|
6111
|
+
} catch (e) {
|
|
6112
|
+
const msg = String(e?.message || e);
|
|
6113
|
+
const transient = /terminated|other side closed|socket|ECONNRESET|ECONNREFUSED|UND_ERR|fetch failed|network|aborted|timeout/i.test(msg) && !/upload-pack \d{3}:/.test(msg);
|
|
6114
|
+
if (!transient || attempt >= MAX_ATTEMPTS) throw e;
|
|
6115
|
+
await new Promise((res) => setTimeout(res, Math.min(4e3, 400 * attempt)));
|
|
6116
|
+
}
|
|
6117
|
+
}
|
|
6108
6118
|
const { lines, rest } = readPktLines(buf, 0);
|
|
6109
6119
|
const packChunks = [];
|
|
6110
6120
|
let sideband = false;
|
package/dart/pubspec.yaml
CHANGED
|
@@ -7,7 +7,7 @@ repository: https://github.com/Captain-App/nomos2
|
|
|
7
7
|
description: >-
|
|
8
8
|
Nomos 2 Dart client — generated domain values, intent payloads and application
|
|
9
9
|
lifecycle types over the typed Nomos kernel protocol and USDA custody model.
|
|
10
|
-
version: 0.64.
|
|
10
|
+
version: 0.64.40
|
|
11
11
|
|
|
12
12
|
environment:
|
|
13
13
|
sdk: ^3.5.0
|
package/package.json
CHANGED
|
@@ -6612,6 +6612,56 @@ async function lsRefs(remote, headers = {}, { service = "git-upload-pack", prefi
|
|
|
6612
6612
|
}
|
|
6613
6613
|
return refs;
|
|
6614
6614
|
}
|
|
6615
|
+
async function fetchPack(remote, headers = {}, { wants = [], haves = [] } = {}) {
|
|
6616
|
+
wants = wants.filter((w) => /^[0-9a-f]{40}$/.test(w) && w !== ZERO_OID);
|
|
6617
|
+
if (!wants.length) return null;
|
|
6618
|
+
const caps = "ofs-delta agent=nomos-cloud";
|
|
6619
|
+
const parts = [];
|
|
6620
|
+
wants.forEach((w, i) => parts.push(pkt(`want ${w}${i === 0 ? " " + caps : ""}
|
|
6621
|
+
`)));
|
|
6622
|
+
parts.push(FLUSH);
|
|
6623
|
+
for (const h of haves) if (/^[0-9a-f]{40}$/.test(h) && h !== ZERO_OID) parts.push(pkt(`have ${h}
|
|
6624
|
+
`));
|
|
6625
|
+
parts.push(pkt("done\n"));
|
|
6626
|
+
const body = concatBytes(parts);
|
|
6627
|
+
const reqHeaders = { "content-type": "application/x-git-upload-pack-request", "accept": "application/x-git-upload-pack-result", ...headers };
|
|
6628
|
+
const MAX_ATTEMPTS = 6;
|
|
6629
|
+
let buf;
|
|
6630
|
+
for (let attempt = 1; ; attempt++) {
|
|
6631
|
+
try {
|
|
6632
|
+
const r = await fetch(`${remote}/git-upload-pack`, { method: "POST", headers: reqHeaders, body, signal: AbortSignal.timeout(18e4) });
|
|
6633
|
+
if (!r.ok) throw new Error(`upload-pack ${r.status}: ${(await r.text()).slice(0, 180)}`);
|
|
6634
|
+
buf = new Uint8Array(await r.arrayBuffer());
|
|
6635
|
+
break;
|
|
6636
|
+
} catch (e) {
|
|
6637
|
+
const msg = String(e?.message || e);
|
|
6638
|
+
const transient = /terminated|other side closed|socket|ECONNRESET|ECONNREFUSED|UND_ERR|fetch failed|network|aborted|timeout/i.test(msg) && !/upload-pack \d{3}:/.test(msg);
|
|
6639
|
+
if (!transient || attempt >= MAX_ATTEMPTS) throw e;
|
|
6640
|
+
await new Promise((res) => setTimeout(res, Math.min(4e3, 400 * attempt)));
|
|
6641
|
+
}
|
|
6642
|
+
}
|
|
6643
|
+
const { lines, rest } = readPktLines(buf, 0);
|
|
6644
|
+
const packChunks = [];
|
|
6645
|
+
let sideband = false;
|
|
6646
|
+
for (const line of lines) {
|
|
6647
|
+
if (line.length === 0) continue;
|
|
6648
|
+
const head = dec3.decode(line.subarray(0, Math.min(line.length, 3)));
|
|
6649
|
+
if (head === "NAK" || head === "ACK" || head === "ERR" || head.startsWith("sha")) continue;
|
|
6650
|
+
const band = line[0];
|
|
6651
|
+
if (band === 1) {
|
|
6652
|
+
packChunks.push(line.subarray(1));
|
|
6653
|
+
sideband = true;
|
|
6654
|
+
} else if (band === 2) {
|
|
6655
|
+
} else if (band === 3) {
|
|
6656
|
+
throw new Error("upload-pack fatal: " + dec3.decode(line.subarray(1)).slice(0, 180));
|
|
6657
|
+
}
|
|
6658
|
+
}
|
|
6659
|
+
const pack = sideband ? concatBytes(packChunks) : buf.subarray(rest);
|
|
6660
|
+
if (pack.length < 12 || dec3.decode(pack.subarray(0, 4)) !== "PACK") {
|
|
6661
|
+
throw new Error(`upload-pack: no pack in response (${pack.length}B, sideband=${sideband})`);
|
|
6662
|
+
}
|
|
6663
|
+
return pack;
|
|
6664
|
+
}
|
|
6615
6665
|
async function pushPack(remote, headers = {}, { updates = [], pack = null } = {}, fetchImpl = fetch) {
|
|
6616
6666
|
if (!updates.length) return { ok: true, noop: true };
|
|
6617
6667
|
const body = concatBytes([receivePackCommandPrefix(updates), pack && pack.length ? pack : EMPTY_PACK]);
|
|
@@ -12036,6 +12086,33 @@ ${String(commit).toLowerCase()}`;
|
|
|
12036
12086
|
const addReadRemote = async () => {
|
|
12037
12087
|
};
|
|
12038
12088
|
const applyPack = (packB64, head) => provider.applyPack(packB64, head);
|
|
12089
|
+
const directFullClone = async (label) => {
|
|
12090
|
+
let lease = null;
|
|
12091
|
+
try {
|
|
12092
|
+
const r = await _fetch(`${base}/v2/workspaces/${workspace}/git-read-lease`, { headers: authHeaders });
|
|
12093
|
+
if (r.ok) {
|
|
12094
|
+
const d = await r.json();
|
|
12095
|
+
if (d && d.ok && typeof d.remote === "string" && typeof d.token === "string") lease = { remote: d.remote, token: d.token };
|
|
12096
|
+
}
|
|
12097
|
+
} catch {
|
|
12098
|
+
}
|
|
12099
|
+
if (!lease) return false;
|
|
12100
|
+
const _f = span("receive", `${label} (direct artifacts)`, "cfetch", {});
|
|
12101
|
+
try {
|
|
12102
|
+
const auth = { Authorization: "Basic " + btoa("x:" + lease.token) };
|
|
12103
|
+
const refs = await lsRefs(lease.remote, auth, { service: "git-upload-pack", prefix: "refs/heads/main" });
|
|
12104
|
+
const head = refs["refs/heads/main"];
|
|
12105
|
+
if (!head || !/^[0-9a-f]{40}$/i.test(head)) return false;
|
|
12106
|
+
const pack = await fetchPack(lease.remote, auth, { wants: [head] });
|
|
12107
|
+
if (!pack || !pack.byteLength) return false;
|
|
12108
|
+
applyPack(nmsInternalSnapshotToBase64(pack), head);
|
|
12109
|
+
return true;
|
|
12110
|
+
} catch {
|
|
12111
|
+
return false;
|
|
12112
|
+
} finally {
|
|
12113
|
+
_f.end();
|
|
12114
|
+
}
|
|
12115
|
+
};
|
|
12039
12116
|
try {
|
|
12040
12117
|
await provider.boot({ base, workspace, branch: BRANCH, repoArg, restoreSnapshot: restoreFrom || null, fetchImpl: _fetch, authHeaders });
|
|
12041
12118
|
} catch (error) {
|
|
@@ -12213,7 +12290,7 @@ ${String(commit).toLowerCase()}`;
|
|
|
12213
12290
|
_bootLedger.end();
|
|
12214
12291
|
if (!allowUnborn) throw new Error(`workspace '${workspace}' has no ledger main \u2014 create it first (POST /v2/workspaces/${workspace})`);
|
|
12215
12292
|
} else {
|
|
12216
|
-
await fetchAndApplyPack("", "boot: fetch full pack");
|
|
12293
|
+
if (!await directFullClone("boot: full pack")) await fetchAndApplyPack("", "boot: fetch full pack");
|
|
12217
12294
|
const mounted = await mountWithResidency(remoteMain, "boot restore");
|
|
12218
12295
|
if (mounted && typeof mounted === "object" && mounted.kernelSpans) {
|
|
12219
12296
|
bootKernelSpans = mounted.kernelSpans;
|
|
@@ -12748,7 +12825,7 @@ ${String(commit).toLowerCase()}`;
|
|
|
12748
12825
|
const forceFullMainHistory = async (why, _cause) => {
|
|
12749
12826
|
const _full = span("receive", `${why}: re-pull FULL main`, "cfetch");
|
|
12750
12827
|
try {
|
|
12751
|
-
await fetchAndApplyPack("", `${why}: full main pack`);
|
|
12828
|
+
if (!await directFullClone(`${why}: full main`)) await fetchAndApplyPack("", `${why}: full main pack`);
|
|
12752
12829
|
} finally {
|
|
12753
12830
|
_full.end();
|
|
12754
12831
|
}
|
package/src/aggregate.ts
CHANGED
|
@@ -61,14 +61,20 @@ const READ_CAP_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
|
|
|
61
61
|
/**
|
|
62
62
|
* THE READ AUDIENCE (secure-by-default rework — `architecture/secure_by_default_dsl.md`). Every
|
|
63
63
|
* aggregate DECLARES who may read it; there is no silent default (the compiler refuses an undeclared
|
|
64
|
-
* aggregate).
|
|
64
|
+
* aggregate). Four choices:
|
|
65
65
|
* • `public` — anyone reads (plaintext). The deliberate exception.
|
|
66
66
|
* • `readableBy` — holders of `cap` read (gate-enforced). Sugar over the shipped read-visibility
|
|
67
67
|
* (`requires(cap)`); per-field `.secret()` escalates to team-E2E.
|
|
68
|
+
* • `ownerReadable` — the workspace owner reads (plaintext, gate-enforced). Owner-only WITHOUT the
|
|
69
|
+
* confidentiality of `owned`: authorization and encryption are separable concerns,
|
|
70
|
+
* and a denormalised access index / discovery card needs the first, not the second.
|
|
71
|
+
* Gates on the framework's workspace-level owner-read relation, which every workspace
|
|
72
|
+
* already holds from birth — so an existing chain adopts it with no backfill and the
|
|
73
|
+
* operator can still serve the plaintext. This is `owned` minus the encryption pass.
|
|
68
74
|
* • `owned` — the owner reads; owner = the verified authoring principal, stamped into the
|
|
69
75
|
* obscure framework field. The read rule is record-grained so later sharing can
|
|
70
76
|
* extend one record without changing this declaration; content is operator-blind.
|
|
71
|
-
* `.public()`/`.readableBy(cap)`/`.owned()` are the fluent declarations on the
|
|
77
|
+
* `.public()`/`.readableBy(cap)`/`.ownerReadable()`/`.owned()` are the fluent declarations on the handle.
|
|
72
78
|
*/
|
|
73
79
|
export type Audience =
|
|
74
80
|
| { readonly kind: "public" }
|
|
@@ -320,6 +326,11 @@ export interface AudienceMethods<Id extends string, F extends Record<string, Fie
|
|
|
320
326
|
public(): NameableHandle<Id, F>;
|
|
321
327
|
/** Holders of `capability` may read (gate-enforced) — sugar over `requires(cap)`. */
|
|
322
328
|
readableBy(capability: string): NameableHandle<Id, F>;
|
|
329
|
+
/** The workspace owner may read (plaintext, gate-enforced). Owner-only authorization WITHOUT `owned`'s
|
|
330
|
+
* operator-blind encryption — for a denormalised access index / discovery card the operator legitimately
|
|
331
|
+
* serves. Gates on the framework owner-read relation every workspace holds from birth, so it adopts on an
|
|
332
|
+
* existing chain with no backfill. Tenant code never names the framework relation. See {@link owned}. */
|
|
333
|
+
ownerReadable(): NameableHandle<Id, F>;
|
|
323
334
|
/** The verified creator owns this record. Nomos stamps ownership, seats its per-record read relation and
|
|
324
335
|
* encrypts every non-`.meta()` content field; tenant code never names the framework field/relation. */
|
|
325
336
|
owned(): NameableHandle<Id, F>;
|
|
@@ -679,6 +690,14 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
|
|
|
679
690
|
const v = requires(cap); // validates the capability name
|
|
680
691
|
return build({ kind: "readableBy", cap: v.requires }, v, contracts, dartNameDecl);
|
|
681
692
|
},
|
|
693
|
+
// Owner-only, PLAINTEXT. Deliberately a `readableBy` audience gating on the framework owner-read
|
|
694
|
+
// relation — NOT `kind: "owned"`, so the encryption lowering below is skipped and content stays
|
|
695
|
+
// operator-readable. The relation is seated at workspace scope (`workspace:self#<rel>`) at birth
|
|
696
|
+
// (see framework/home.ts genesis), which is why an existing chain resolves it with no backfill.
|
|
697
|
+
// `owned()` is the same read gate PLUS per-record encryption; this is the confidentiality-free rung.
|
|
698
|
+
ownerReadable: () => {
|
|
699
|
+
return build({ kind: "readableBy", cap: OWNED_READ_RELATION }, requires(OWNED_READ_RELATION), contracts, dartNameDecl);
|
|
700
|
+
},
|
|
682
701
|
owned: () => {
|
|
683
702
|
return build({ kind: "owned", ownerField: OWNER_FIELD }, requires(OWNED_READ_RELATION), contracts, dartNameDecl);
|
|
684
703
|
},
|
|
@@ -57,7 +57,7 @@ export function capabilityDeclarationsOf(v: unknown): Record<string, unknown>[]
|
|
|
57
57
|
// `relations` carries the receipt fence's relation SCHEMA. It MUST expand with the bundle:
|
|
58
58
|
// `.requires(...)` is inert in a law that declares no relation schema, so omitting it would
|
|
59
59
|
// leave every capability looking fenced while enforcing nothing.
|
|
60
|
-
const PIECES = ["aggregate", "obligation", "complete", "fail", "block", "deadLetter", "tasks", "relations", "grantProvider", "revokeProvider"] as const;
|
|
60
|
+
const PIECES = ["aggregate", "obligation", "complete", "fail", "block", "deadLetter", "reschedule", "tasks", "relations", "grantProvider", "revokeProvider"] as const;
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
63
|
* Expand every `capability()` bundle in an exports bag into discoverable
|
|
@@ -1664,8 +1664,8 @@ const NOMOS_TYPES_DEP = "^0.8.8";
|
|
|
1664
1664
|
// 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
|
|
1665
1665
|
// DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
|
|
1666
1666
|
// REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
|
|
1667
|
-
const NOMOS_CLIENT_DEP = "^0.64.
|
|
1668
|
-
const NOMOS_FLUTTER_DEP = "^0.63.
|
|
1667
|
+
const NOMOS_CLIENT_DEP = "^0.64.40";
|
|
1668
|
+
const NOMOS_FLUTTER_DEP = "^0.63.68";
|
|
1669
1669
|
// DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
|
|
1670
1670
|
// and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
|
|
1671
1671
|
// land back in dependency_overrides (the exact co2 report this closes, third occurrence).
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
* (`architecture/capability_credentials.md`).
|
|
54
54
|
*/
|
|
55
55
|
import { z } from "zod";
|
|
56
|
-
import { instance } from "../aggregate.js";
|
|
56
|
+
import { instance, type AggregateHandle } from "../aggregate.js";
|
|
57
57
|
import type { Field } from "../fields.js";
|
|
58
58
|
import { set, type PlannedOp } from "../ops.js";
|
|
59
59
|
import { query, type QueryDecl } from "../query.js";
|
|
@@ -215,6 +215,20 @@ export function capability<
|
|
|
215
215
|
scheduleAfterMs?: number;
|
|
216
216
|
/** Time from acceptance until the obligation is overdue. Defaults to 24h. */
|
|
217
217
|
deadlineAfterMs?: number;
|
|
218
|
+
/** How many times an overdue-but-still-`requested` obligation may be RE-OPENED in place by
|
|
219
|
+
* the bounded `reschedule` verb before the next reschedule dead-letters it. Defaults to 5. */
|
|
220
|
+
maxRescheduleAttempts?: number;
|
|
221
|
+
/**
|
|
222
|
+
* EXISTENCE PRECONDITIONS on the oblige verb: each entry names a request field carrying a
|
|
223
|
+
* referenced aggregate id; the oblige REFUSES (typed, kernel-evaluated at every gate) when
|
|
224
|
+
* that row does not exist, so a typo'd address can never seal an unfulfillable obligation.
|
|
225
|
+
* `rejectCode` defaults to `"<field>-not-found"`. Era-additive.
|
|
226
|
+
*/
|
|
227
|
+
requestRefsExist?: ReadonlyArray<{
|
|
228
|
+
field: keyof Req & string;
|
|
229
|
+
aggregate: AggregateHandle;
|
|
230
|
+
rejectCode?: string;
|
|
231
|
+
}>;
|
|
218
232
|
/**
|
|
219
233
|
* THE ACKNOWLEDGED WAIVER — open this capability's Receipts to any authorised author.
|
|
220
234
|
*
|
|
@@ -280,6 +294,7 @@ export function capability<
|
|
|
280
294
|
failDirectiveId: `fail${pascal}`,
|
|
281
295
|
blockDirectiveId: `block${pascal}`,
|
|
282
296
|
deadLetterDirectiveId: `deadLetter${pascal}`,
|
|
297
|
+
rescheduleDirectiveId: `reschedule${pascal}`,
|
|
283
298
|
params,
|
|
284
299
|
paramsSchema: requestShape,
|
|
285
300
|
// The loop is dynamic (declared field names), so the per-field `set()` typing
|
|
@@ -308,6 +323,18 @@ export function capability<
|
|
|
308
323
|
...(spec.deadlineAfterMs !== undefined
|
|
309
324
|
? { deadlineAfterMs: spec.deadlineAfterMs }
|
|
310
325
|
: {}),
|
|
326
|
+
...(spec.maxRescheduleAttempts !== undefined
|
|
327
|
+
? { maxRescheduleAttempts: spec.maxRescheduleAttempts }
|
|
328
|
+
: {}),
|
|
329
|
+
...(spec.requestRefsExist !== undefined
|
|
330
|
+
? {
|
|
331
|
+
obligationRefsExist: spec.requestRefsExist.map((pre) => ({
|
|
332
|
+
aggregate: pre.aggregate,
|
|
333
|
+
idField: pre.field,
|
|
334
|
+
rejectCode: pre.rejectCode ?? `${pre.field}-not-found`,
|
|
335
|
+
})),
|
|
336
|
+
}
|
|
337
|
+
: {}),
|
|
311
338
|
});
|
|
312
339
|
|
|
313
340
|
const camel = pascal[0]!.toLowerCase() + pascal.slice(1);
|
|
@@ -382,6 +409,18 @@ export function capability<
|
|
|
382
409
|
fail: fenced(quartet.fail),
|
|
383
410
|
block: fenced(quartet.block),
|
|
384
411
|
deadLetter: fenced(quartet.deadLetter),
|
|
412
|
+
// The bounded `reschedule` verb is a DRUM-OPERATOR action: the operator decides to retry (or
|
|
413
|
+
// dead-letter) a stuck obligation; the provider merely FULFILS. So fence it on the SAME
|
|
414
|
+
// authority as the obligation REQUEST (`requestRequires`, e.g. admin) — NOT the provider
|
|
415
|
+
// receipt relation. Otherwise the operator who is allowed to REQUEST work cannot re-open it
|
|
416
|
+
// when the provider stalls, which is exactly the drum's job. Falls back to provider-fenced
|
|
417
|
+
// (like the receipts) when the capability declares no `requestRequires`.
|
|
418
|
+
reschedule: spec.requestRequires === undefined
|
|
419
|
+
? fenced(quartet.reschedule)
|
|
420
|
+
: quartet.reschedule.requires(spec.requestRequires.relation, {
|
|
421
|
+
...(spec.requestRequires.object !== undefined ? { object: spec.requestRequires.object } : {}),
|
|
422
|
+
...(spec.requestRequires.objectFrom !== undefined ? { objectFrom: spec.requestRequires.objectFrom } : {}),
|
|
423
|
+
}),
|
|
385
424
|
// The relation SCHEMA rides with the bundle so the gate can never be inert (a law without
|
|
386
425
|
// a relation schema does not enforce `.requires` at all).
|
|
387
426
|
relations: capabilityProviderRelations,
|
|
@@ -412,6 +451,7 @@ export function capability<
|
|
|
412
451
|
declaration.fail,
|
|
413
452
|
declaration.block,
|
|
414
453
|
declaration.deadLetter,
|
|
454
|
+
declaration.reschedule,
|
|
415
455
|
]) {
|
|
416
456
|
Object.defineProperty(lifecycle, NOMOS_CAPABILITY_DECLARATIONS, {
|
|
417
457
|
value: Object.freeze([declaration]),
|
|
@@ -72,6 +72,8 @@ import { attributionField, t, type Field } from "../fields.js";
|
|
|
72
72
|
import { Lww } from "../drivers.js";
|
|
73
73
|
import { set, type PlannedOp } from "../ops.js";
|
|
74
74
|
import { directive, type RequirableDirective } from "../directive.js";
|
|
75
|
+
import { query, type QueryDecl } from "../query.js";
|
|
76
|
+
import { readFrameworkDeclared } from "../read.js";
|
|
75
77
|
import { executeDirectiveToIntent } from "../wire_encode.js";
|
|
76
78
|
import type { WireIntent } from "../wire.js";
|
|
77
79
|
import type { Ports } from "../ctx.js";
|
|
@@ -124,6 +126,14 @@ export type ImpureCapabilityStatus = (typeof IMPURE_CAPABILITY_STATUSES)[number]
|
|
|
124
126
|
* - nextAction Lww (OPTIONAL) — human/host-action hint, e.g. refreshAuth.
|
|
125
127
|
* - deadLetterReason Lww (OPTIONAL) — why the obligation was moved to the DLQ.
|
|
126
128
|
* - deadLetteredAt Int (OPTIONAL) — epoch-ms the obligation entered the DLQ.
|
|
129
|
+
* - attempts Int (OPTIONAL) — how many times an overdue-but-still-`requested`
|
|
130
|
+
* obligation has been RESCHEDULED in place (the bounded in-law
|
|
131
|
+
* `reschedule` verb). Folded ONLY by a reschedule that re-opens the
|
|
132
|
+
* task, so a freshly-obligated task never writes it — ABSENT decodes as
|
|
133
|
+
* 0 (the same read-decode discipline as the other optionals). It is the
|
|
134
|
+
* bound the DLQ cap (`maxRescheduleAttempts`) is judged against: once an
|
|
135
|
+
* overdue task has already been rescheduled that many times, the next
|
|
136
|
+
* reschedule dead-letters it instead of re-opening.
|
|
127
137
|
*
|
|
128
138
|
* The OPTIONALs are `.optional()` so a freshly-obligated task (which folds none of them)
|
|
129
139
|
* decodes on the generated read type — the same read-decode discipline the rest of the
|
|
@@ -144,6 +154,19 @@ export const IMPURE_CAPABILITY_ENVELOPE = {
|
|
|
144
154
|
nextAction: t.string().merge(Lww).optional(),
|
|
145
155
|
deadLetterReason: t.string().merge(Lww).optional(),
|
|
146
156
|
deadLetteredAt: t.int().merge(Lww).optional(),
|
|
157
|
+
// The RESCHEDULE ATTEMPTS counter (OPTIONAL): how many times an overdue-but-non-terminal
|
|
158
|
+
// (`requested`) obligation has been re-opened in place by the bounded `reschedule` verb.
|
|
159
|
+
// MUST be `.optional()` — a freshly-obligated task folds it NEVER, so it decodes as ABSENT
|
|
160
|
+
// (read as 0); making it required would break decode of every pre-existing task AND the era
|
|
161
|
+
// rule (old chains fold no `attempts`). Judged against `maxRescheduleAttempts` to bound retries.
|
|
162
|
+
attempts: t.int().merge(Lww).optional(),
|
|
163
|
+
// TEMPO LIMIT-BREACH facts (OPTIONAL — era-safe): stamped by the bounded `reschedule` verb
|
|
164
|
+
// when the drum judges a still-`requested` task past its DECLARED `limitMs` (a tenant request
|
|
165
|
+
// field, when the capability declares one). Lww-last is enough for the projection — every
|
|
166
|
+
// stamp is an ordinary Intent in the chain, so the full breach history stays auditable.
|
|
167
|
+
// Limits are law; ESTIMATE breaches are read-side telemetry and never fold here.
|
|
168
|
+
limitBreachedAt: t.int().merge(Lww).optional(),
|
|
169
|
+
limitBreachElapsedMs: t.int().merge(Lww).optional(),
|
|
147
170
|
// The FRAMEWORK Obligation↔Receipt CLOSURE link (OPTIONAL): the id of the Obligation this
|
|
148
171
|
// RECEIPT closes (== the task's own requestId — the reactor mutates the task instance).
|
|
149
172
|
// GENERIC, written by every impureCapability Receipt (complete OR fail), so the read
|
|
@@ -246,6 +269,18 @@ const deadLetterSchema = z.object({
|
|
|
246
269
|
deadLetteredAt: z.number().int(),
|
|
247
270
|
});
|
|
248
271
|
|
|
272
|
+
/**
|
|
273
|
+
* The RESCHEDULE payload: which obligation to judge. Deliberately carries ONLY the requestId
|
|
274
|
+
* — the reschedule verb reads the task's OWN committed lifecycle state (status / deadlineAt /
|
|
275
|
+
* attempts) through the captured-read lane and reads "now" from the deterministic captured
|
|
276
|
+
* clock, so a caller cannot spoof the overdue-ness verdict or the fresh timing. The provider /
|
|
277
|
+
* drum authority authors it; the plan decides in-law whether it is a no-op, an in-place
|
|
278
|
+
* re-open, or a dead-letter.
|
|
279
|
+
*/
|
|
280
|
+
const rescheduleSchema = z.object({
|
|
281
|
+
requestId: z.string(),
|
|
282
|
+
});
|
|
283
|
+
|
|
249
284
|
/**
|
|
250
285
|
* The three plan builders. Each takes the task aggregate handle + the (already
|
|
251
286
|
* Zod-validated) payload and returns the `PlannedOp[]` writing the envelope. A tenant
|
|
@@ -372,6 +407,42 @@ export function deadLetterOps(
|
|
|
372
407
|
];
|
|
373
408
|
}
|
|
374
409
|
|
|
410
|
+
/**
|
|
411
|
+
* rescheduleOps — the IN-PLACE RE-OPEN half of the bounded `reschedule` verb (NOT a receipt,
|
|
412
|
+
* NOT terminal). Advances the timing window (`scheduledAt`/`deadlineAt`), bumps the `attempts`
|
|
413
|
+
* counter, and KEEPS `status:'requested'`. Because the task stays `requested` (non-terminal),
|
|
414
|
+
* the kernel's terminal-once gate does NOT treat this as a terminal transition — it is the one
|
|
415
|
+
* legal in-place mutation of a live obligation's timing (kernel-verified: mutating a
|
|
416
|
+
* still-`requested` task is not a receipt against a terminal obligation). Symmetrical with the
|
|
417
|
+
* terminal op-builders above: it writes ONLY envelope fields, bound to the task instance (#105).
|
|
418
|
+
*/
|
|
419
|
+
export function rescheduleOps(
|
|
420
|
+
aggregateId: string,
|
|
421
|
+
p: {
|
|
422
|
+
requestId: string;
|
|
423
|
+
scheduledAt: number;
|
|
424
|
+
deadlineAt: number;
|
|
425
|
+
attempts: number;
|
|
426
|
+
/** Present iff this re-open was forced by a DECLARED-LIMIT breach (not the deadline). */
|
|
427
|
+
limitBreach?: { at: number; elapsedMs: number };
|
|
428
|
+
},
|
|
429
|
+
): PlannedOp[] {
|
|
430
|
+
const taskInstance = envelopeInstance(aggregateId, p.requestId);
|
|
431
|
+
return [
|
|
432
|
+
set(taskInstance, "scheduledAt", p.scheduledAt),
|
|
433
|
+
set(taskInstance, "deadlineAt", p.deadlineAt),
|
|
434
|
+
set(taskInstance, "attempts", p.attempts),
|
|
435
|
+
// KEEP status:'requested' — re-opening a non-terminal task, never a terminal transition.
|
|
436
|
+
set(taskInstance, "status", "requested"),
|
|
437
|
+
...(p.limitBreach !== undefined
|
|
438
|
+
? [
|
|
439
|
+
set(taskInstance, "limitBreachedAt", p.limitBreach.at),
|
|
440
|
+
set(taskInstance, "limitBreachElapsedMs", p.limitBreach.elapsedMs),
|
|
441
|
+
]
|
|
442
|
+
: []),
|
|
443
|
+
];
|
|
444
|
+
}
|
|
445
|
+
|
|
375
446
|
/**
|
|
376
447
|
* The generated directive QUARTET for a impure capability. A domain dev calls
|
|
377
448
|
* {@link impureCapability} ONCE and gets:
|
|
@@ -412,6 +483,15 @@ export interface ImpureCapability<
|
|
|
412
483
|
readonly fail: RequirableDirective<z.infer<typeof failSchema>>;
|
|
413
484
|
readonly block: RequirableDirective<z.infer<typeof blockSchema>>;
|
|
414
485
|
readonly deadLetter: RequirableDirective<z.infer<typeof deadLetterSchema>>;
|
|
486
|
+
/**
|
|
487
|
+
* The bounded IN-LAW `reschedule` lifecycle verb (provider / drum-authority action). Its plan
|
|
488
|
+
* reads the task's OWN committed `{status, deadlineAt, attempts}` through the captured-read
|
|
489
|
+
* lane and judges overdue-ness against the deterministic captured clock, then either NO-OPs
|
|
490
|
+
* (terminal, or not yet overdue), RE-OPENS in place (`rescheduleOps`, bumping `attempts` +
|
|
491
|
+
* advancing the window, keeping `status:'requested'`), or DEAD-LETTERS when at/over the
|
|
492
|
+
* `maxRescheduleAttempts` cap (reusing `deadLetterOps`). Kernel-legal without any kernel change.
|
|
493
|
+
*/
|
|
494
|
+
readonly reschedule: RequirableDirective<z.infer<typeof rescheduleSchema>>;
|
|
415
495
|
readonly instanceId: (requestId: string) => string;
|
|
416
496
|
/**
|
|
417
497
|
* The CAPABILITY CREDENTIAL this task's provider must hold to fulfil Orders
|
|
@@ -485,6 +565,8 @@ export function impureCapability<
|
|
|
485
565
|
failDirectiveId: FailId;
|
|
486
566
|
blockDirectiveId: string;
|
|
487
567
|
deadLetterDirectiveId: string;
|
|
568
|
+
/** The wire id for the bounded `reschedule` verb — caller-supplied, like the DLQ id. */
|
|
569
|
+
rescheduleDirectiveId: string;
|
|
488
570
|
/** The task's request-param FIELDS (merged onto the envelope for the aggregate shape). */
|
|
489
571
|
params: F;
|
|
490
572
|
/** The task's request-param Zod SHAPE (merged onto the obligation envelope schema). */
|
|
@@ -526,6 +608,22 @@ export function impureCapability<
|
|
|
526
608
|
scheduleAfterMs?: number;
|
|
527
609
|
/** Time from acceptance until the task is overdue. Defaults to 24 hours. */
|
|
528
610
|
deadlineAfterMs?: number;
|
|
611
|
+
/** How many times an overdue-but-still-`requested` obligation may be RE-OPENED in place by
|
|
612
|
+
* `reschedule` before the next reschedule dead-letters it instead. Defaults to 5. */
|
|
613
|
+
maxRescheduleAttempts?: number;
|
|
614
|
+
/**
|
|
615
|
+
* OPTIONAL EXISTENCE PRECONDITIONS on the Obligation verb (kernel-lowered point guards):
|
|
616
|
+
* each entry refuses the oblige when the row the named request field references is ABSENT,
|
|
617
|
+
* so a typo'd address can never seal an unfulfillable obligation that only surfaces as
|
|
618
|
+
* tempo-breach noise later. Era-additive — declaring nothing changes nothing on the wire.
|
|
619
|
+
*/
|
|
620
|
+
obligationRefsExist?: ReadonlyArray<{
|
|
621
|
+
aggregate: AggregateHandle;
|
|
622
|
+
/** The obligation request field carrying the referenced aggregate's id. */
|
|
623
|
+
idField: string;
|
|
624
|
+
/** The typed refusal code (e.g. `"delivery-product-not-found"`). */
|
|
625
|
+
rejectCode: string;
|
|
626
|
+
}>;
|
|
529
627
|
}): ImpureCapability<Id, ParamsShape, F, ReceiptShape> {
|
|
530
628
|
const taskAggregate = impureCapabilityAggregate(spec.aggregateId, spec.params);
|
|
531
629
|
const obligationPayload = z.object(spec.paramsSchema);
|
|
@@ -549,13 +647,37 @@ export function impureCapability<
|
|
|
549
647
|
const typedFailSchema = failSchema.extend(typedRequestId);
|
|
550
648
|
const typedBlockSchema = blockSchema.extend(typedRequestId);
|
|
551
649
|
const typedDeadLetterSchema = deadLetterSchema.extend(typedRequestId);
|
|
552
|
-
|
|
553
|
-
|
|
650
|
+
const typedRescheduleSchema = rescheduleSchema.extend(typedRequestId);
|
|
651
|
+
// THE BY-ID SELF-READ (the #58 captured-read lane): the reschedule plan reads the task's OWN
|
|
652
|
+
// committed lifecycle state by its requestId (== the instance id), keyed on the envelope's
|
|
653
|
+
// indexed `requestId` field. Declared here in the factory (the `tasks`/TasksByStatus query is
|
|
654
|
+
// keyed on `status`, so this is the by-id variant the reschedule verb needs) and DECLARED on
|
|
655
|
+
// the directive via `.reads(...)`; the compiler seats it in the domain automatically off that
|
|
656
|
+
// declaration, so it is routable even though it is not separately exported.
|
|
657
|
+
const taskById: QueryDecl = query(`${spec.aggregateId}ById`)
|
|
658
|
+
.key("requestId")
|
|
659
|
+
.returns(taskAggregate);
|
|
660
|
+
const maxRescheduleAttempts = spec.maxRescheduleAttempts ?? 5;
|
|
661
|
+
|
|
662
|
+
let obligationBuilder = directive(
|
|
554
663
|
spec.obligationDirectiveId,
|
|
555
664
|
)
|
|
556
665
|
.creates(taskAggregate)
|
|
557
|
-
.payload(obligationPayload)
|
|
558
|
-
|
|
666
|
+
.payload(obligationPayload);
|
|
667
|
+
// THE EXISTENCE FENCE (CO2-1624): refuse an oblige whose request references a row that does
|
|
668
|
+
// not exist. Chained BEFORE `.plan` so each guard lowers into the manifest as a typed point
|
|
669
|
+
// precondition the kernel evaluates at every gate — author, edge admission, verify_chain.
|
|
670
|
+
for (const pre of spec.obligationRefsExist ?? []) {
|
|
671
|
+
obligationBuilder = obligationBuilder.precondition(
|
|
672
|
+
pre.aggregate,
|
|
673
|
+
({ row, intent }) => ({
|
|
674
|
+
id: intent.field(pre.idField as never) as never,
|
|
675
|
+
holds: row.exists(),
|
|
676
|
+
}),
|
|
677
|
+
pre.rejectCode,
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
const obligationDirective = obligationBuilder.plan((p, ctx) => {
|
|
559
681
|
// The caller supplies BUSINESS PARAMS only. Nomos owns the task identity,
|
|
560
682
|
// provenance and lifecycle clock so generated clients cannot accidentally
|
|
561
683
|
// spoof or hand-roll the obligation envelope.
|
|
@@ -578,6 +700,7 @@ export function impureCapability<
|
|
|
578
700
|
.set("status", "requested");
|
|
579
701
|
return spec.writeParams(impureCapabilityInstanceId(requestId), params);
|
|
580
702
|
});
|
|
703
|
+
const obligation = obligationDirective as RequirableDirective<ObligationPayload<ParamsShape>>;
|
|
581
704
|
|
|
582
705
|
// The two branches above are distinct object schemas; the declared payload TYPE is the
|
|
583
706
|
// `CompletePayload<ReceiptShape>` conditional, so name it once here rather than leaking a
|
|
@@ -614,6 +737,73 @@ export function impureCapability<
|
|
|
614
737
|
.payload(typedDeadLetterSchema)
|
|
615
738
|
.plan((p) => deadLetterOps(spec.aggregateId, p));
|
|
616
739
|
|
|
740
|
+
// THE BOUNDED `reschedule` VERB. It `.mutates` the task and reads THAT SAME task's committed
|
|
741
|
+
// lifecycle state by id (the captured-read lane, declared via `.reads(taskById)`), judging
|
|
742
|
+
// overdue-ness IN-LAW against the deterministic captured clock — never `Date.now()`. Three
|
|
743
|
+
// outcomes, mirroring the consequent-outbox cap discipline (never touch a terminal task; only
|
|
744
|
+
// re-open genuinely-overdue work; dead-letter once the retry budget is exhausted):
|
|
745
|
+
// 1. status !== "requested" → NO-OP (never mutate a terminal task).
|
|
746
|
+
// 2. now <= deadlineAt AND no declared-limit breach → NO-OP (not overdue yet).
|
|
747
|
+
// 3. overdue & attempts < cap → RE-OPEN in place (rescheduleOps): fresh window +
|
|
748
|
+
// attempts+1, KEEP status:'requested' (kernel-legal —
|
|
749
|
+
// a still-`requested` task is non-terminal).
|
|
750
|
+
// 4. overdue & attempts >= cap → DEAD-LETTER (deadLetterOps, terminal) with the
|
|
751
|
+
// overdue reason.
|
|
752
|
+
const reschedule = directive(spec.rescheduleDirectiveId)
|
|
753
|
+
.mutates(taskAggregate)
|
|
754
|
+
.payload(typedRescheduleSchema)
|
|
755
|
+
.plan((p, ctx) => {
|
|
756
|
+
const now = ctx.clock().physical;
|
|
757
|
+
// Read the task's OWN committed state by id off the #58 captured-read lane. A framework
|
|
758
|
+
// plan bundled via the `@githolon/dsl` barrel reads through `readFrameworkDeclared` (the
|
|
759
|
+
// declared-by-construction twin of `read()`), so it never pins the tenant captured-read
|
|
760
|
+
// compile marker into the barrel lump; the directive DECLARES `taskById` below.
|
|
761
|
+
const rows = readFrameworkDeclared<Array<Record<string, unknown>>>(taskById.id, {
|
|
762
|
+
requestId: p.requestId,
|
|
763
|
+
});
|
|
764
|
+
const raw = Array.isArray(rows) && rows.length > 0 ? rows[0] : undefined;
|
|
765
|
+
// Projected rows are `{type,id,data:{…fields}}`; folded shapes may be flat — accept both.
|
|
766
|
+
const data = (raw && typeof raw === "object" && typeof (raw as { data?: unknown }).data === "object"
|
|
767
|
+
? (raw as { data: Record<string, unknown> }).data
|
|
768
|
+
: raw) as Record<string, unknown> | undefined;
|
|
769
|
+
const status = data?.status as string | undefined;
|
|
770
|
+
// (1) NEVER touch a terminal task — only `requested` is non-terminal.
|
|
771
|
+
if (status !== "requested") return [];
|
|
772
|
+
const deadlineAt = Number(data?.deadlineAt ?? 0);
|
|
773
|
+
// THE DECLARED-LIMIT JUDGMENT (tempo): a capability whose request declares `limitMs`
|
|
774
|
+
// folds it onto this same task row, and elapsed time past that limit is judged exactly
|
|
775
|
+
// like overdue-ness — in-law, from the deterministic captured clock, against the task's
|
|
776
|
+
// own committed `scheduledAt`. Absent `limitMs` (legacy work) ≡ no limit — era-safe.
|
|
777
|
+
const limitMsRaw = data?.limitMs;
|
|
778
|
+
const limitMs = typeof limitMsRaw === "number" && limitMsRaw >= 0 ? limitMsRaw : undefined;
|
|
779
|
+
const scheduledAt = Number(data?.scheduledAt ?? 0);
|
|
780
|
+
const elapsedMs = now - scheduledAt;
|
|
781
|
+
const pastLimit = limitMs !== undefined && scheduledAt > 0 && elapsedMs > limitMs;
|
|
782
|
+
// (2) Neither past the committed deadline NOR past the declared limit — no-op.
|
|
783
|
+
if (now <= deadlineAt && !pastLimit) return [];
|
|
784
|
+
const attempts = Number(data?.attempts ?? 0);
|
|
785
|
+
// (4) Overdue AND the retry budget is exhausted → terminal dead-letter (reuse deadLetterOps).
|
|
786
|
+
if (attempts >= maxRescheduleAttempts) {
|
|
787
|
+
return deadLetterOps(spec.aggregateId, {
|
|
788
|
+
requestId: p.requestId,
|
|
789
|
+
deadLetterReason: pastLimit
|
|
790
|
+
? `overdue: exhausted ${maxRescheduleAttempts} reschedule attempts (declared limit ${limitMs}ms breached; elapsed ${elapsedMs}ms)`
|
|
791
|
+
: `overdue: exhausted ${maxRescheduleAttempts} reschedule attempts`,
|
|
792
|
+
deadLetteredAt: now,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
// (3) Overdue (or limit-breached) but under the cap → RE-OPEN in place with a fresh
|
|
796
|
+
// window + bumped attempts; a limit breach additionally stamps the breach fact.
|
|
797
|
+
return rescheduleOps(spec.aggregateId, {
|
|
798
|
+
requestId: p.requestId,
|
|
799
|
+
scheduledAt: now + (spec.scheduleAfterMs ?? 0),
|
|
800
|
+
deadlineAt: now + (spec.deadlineAfterMs ?? 24 * 60 * 60 * 1000),
|
|
801
|
+
attempts: attempts + 1,
|
|
802
|
+
...(pastLimit ? { limitBreach: { at: now, elapsedMs } } : {}),
|
|
803
|
+
});
|
|
804
|
+
})
|
|
805
|
+
.reads(taskById);
|
|
806
|
+
|
|
617
807
|
return {
|
|
618
808
|
aggregate: taskAggregate,
|
|
619
809
|
obligation,
|
|
@@ -622,6 +812,7 @@ export function impureCapability<
|
|
|
622
812
|
fail,
|
|
623
813
|
block,
|
|
624
814
|
deadLetter,
|
|
815
|
+
reschedule,
|
|
625
816
|
instanceId: impureCapabilityInstanceId,
|
|
626
817
|
// Omit-when-absent (hash discipline): a capability-free declaration carries
|
|
627
818
|
// NO key at all, so its bundled source — and the package hash — is unchanged.
|
|
@@ -39,6 +39,7 @@ export const OWNED_RECOVERY_ACCEPT_DIRECTIVE = "nomos_internal_accept_owned_reco
|
|
|
39
39
|
const OWNED_RECOVERY_FAIL_DIRECTIVE = "nomos_internal_fail_owned_recovery_7c1f5e";
|
|
40
40
|
const OWNED_RECOVERY_BLOCK_DIRECTIVE = "nomos_internal_block_owned_recovery_7c1f5e";
|
|
41
41
|
const OWNED_RECOVERY_DEAD_LETTER_DIRECTIVE = "nomos_internal_dead_letter_owned_recovery_7c1f5e";
|
|
42
|
+
const OWNED_RECOVERY_RESCHEDULE_DIRECTIVE = "nomos_internal_reschedule_owned_recovery_7c1f5e";
|
|
42
43
|
const keySubject = z.string().regex(/^key:[0-9a-f]{64}$/);
|
|
43
44
|
const principal = z.string().regex(/^user:[A-Za-z0-9][A-Za-z0-9_.-]*$/);
|
|
44
45
|
const b64 = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/);
|
|
@@ -99,6 +100,7 @@ const ownedRecoveryLifecycle = impureCapability({
|
|
|
99
100
|
failDirectiveId: OWNED_RECOVERY_FAIL_DIRECTIVE,
|
|
100
101
|
blockDirectiveId: OWNED_RECOVERY_BLOCK_DIRECTIVE,
|
|
101
102
|
deadLetterDirectiveId: OWNED_RECOVERY_DEAD_LETTER_DIRECTIVE,
|
|
103
|
+
rescheduleDirectiveId: OWNED_RECOVERY_RESCHEDULE_DIRECTIVE,
|
|
102
104
|
params: recoveryOrderFields,
|
|
103
105
|
paramsSchema: {
|
|
104
106
|
ownerPrincipal: principal,
|
|
@@ -128,6 +130,7 @@ const hiddenRecoveryLifecycle = {
|
|
|
128
130
|
fail: hidden(ownedRecoveryLifecycle.fail),
|
|
129
131
|
block: hidden(ownedRecoveryLifecycle.block),
|
|
130
132
|
deadLetter: hidden(ownedRecoveryLifecycle.deadLetter),
|
|
133
|
+
reschedule: hidden(ownedRecoveryLifecycle.reschedule),
|
|
131
134
|
};
|
|
132
135
|
const ownedRecoveryCapability = {
|
|
133
136
|
...ownedRecoveryLifecycle,
|
|
@@ -140,6 +143,7 @@ const ownedRecoveryCapability = {
|
|
|
140
143
|
fail: hiddenRecoveryLifecycle.fail,
|
|
141
144
|
block: hiddenRecoveryLifecycle.block,
|
|
142
145
|
deadLetter: hiddenRecoveryLifecycle.deadLetter,
|
|
146
|
+
reschedule: hiddenRecoveryLifecycle.reschedule,
|
|
143
147
|
__isCapabilityDecl: true as const,
|
|
144
148
|
};
|
|
145
149
|
|
|
@@ -98,6 +98,7 @@ const workspaceSyncTask = impureCapability({
|
|
|
98
98
|
failDirectiveId: "failWorkspaceSync",
|
|
99
99
|
blockDirectiveId: "blockWorkspaceSync",
|
|
100
100
|
deadLetterDirectiveId: "deadLetterWorkspaceSync",
|
|
101
|
+
rescheduleDirectiveId: "rescheduleWorkspaceSync",
|
|
101
102
|
params: syncParams,
|
|
102
103
|
paramsSchema: {
|
|
103
104
|
workspaceId: z.string(),
|