@githolon/dsl 0.105.24 → 0.106.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 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 r = await fetch(`${remote}/git-upload-pack`, {
6102
- method: "POST",
6103
- headers: { "content-type": "application/x-git-upload-pack-request", "accept": "application/x-git-upload-pack-result", ...headers },
6104
- body: concatBytes(parts)
6105
- });
6106
- if (!r.ok) throw new Error(`upload-pack ${r.status}: ${(await r.text()).slice(0, 180)}`);
6107
- const buf = new Uint8Array(await r.arrayBuffer());
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.39
10
+ version: 0.64.40
11
11
 
12
12
  environment:
13
13
  sdk: ^3.5.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.105.24",
3
+ "version": "0.106.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
@@ -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). Three choices:
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 aggregate handle.
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.39";
1668
- const NOMOS_FLUTTER_DEP = "^0.63.64";
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).
@@ -215,6 +215,9 @@ 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;
218
221
  /**
219
222
  * THE ACKNOWLEDGED WAIVER — open this capability's Receipts to any authorised author.
220
223
  *
@@ -280,6 +283,7 @@ export function capability<
280
283
  failDirectiveId: `fail${pascal}`,
281
284
  blockDirectiveId: `block${pascal}`,
282
285
  deadLetterDirectiveId: `deadLetter${pascal}`,
286
+ rescheduleDirectiveId: `reschedule${pascal}`,
283
287
  params,
284
288
  paramsSchema: requestShape,
285
289
  // The loop is dynamic (declared field names), so the per-field `set()` typing
@@ -308,6 +312,9 @@ export function capability<
308
312
  ...(spec.deadlineAfterMs !== undefined
309
313
  ? { deadlineAfterMs: spec.deadlineAfterMs }
310
314
  : {}),
315
+ ...(spec.maxRescheduleAttempts !== undefined
316
+ ? { maxRescheduleAttempts: spec.maxRescheduleAttempts }
317
+ : {}),
311
318
  });
312
319
 
313
320
  const camel = pascal[0]!.toLowerCase() + pascal.slice(1);
@@ -382,6 +389,18 @@ export function capability<
382
389
  fail: fenced(quartet.fail),
383
390
  block: fenced(quartet.block),
384
391
  deadLetter: fenced(quartet.deadLetter),
392
+ // The bounded `reschedule` verb is a DRUM-OPERATOR action: the operator decides to retry (or
393
+ // dead-letter) a stuck obligation; the provider merely FULFILS. So fence it on the SAME
394
+ // authority as the obligation REQUEST (`requestRequires`, e.g. admin) — NOT the provider
395
+ // receipt relation. Otherwise the operator who is allowed to REQUEST work cannot re-open it
396
+ // when the provider stalls, which is exactly the drum's job. Falls back to provider-fenced
397
+ // (like the receipts) when the capability declares no `requestRequires`.
398
+ reschedule: spec.requestRequires === undefined
399
+ ? fenced(quartet.reschedule)
400
+ : quartet.reschedule.requires(spec.requestRequires.relation, {
401
+ ...(spec.requestRequires.object !== undefined ? { object: spec.requestRequires.object } : {}),
402
+ ...(spec.requestRequires.objectFrom !== undefined ? { objectFrom: spec.requestRequires.objectFrom } : {}),
403
+ }),
385
404
  // The relation SCHEMA rides with the bundle so the gate can never be inert (a law without
386
405
  // a relation schema does not enforce `.requires` at all).
387
406
  relations: capabilityProviderRelations,
@@ -412,6 +431,7 @@ export function capability<
412
431
  declaration.fail,
413
432
  declaration.block,
414
433
  declaration.deadLetter,
434
+ declaration.reschedule,
415
435
  ]) {
416
436
  Object.defineProperty(lifecycle, NOMOS_CAPABILITY_DECLARATIONS, {
417
437
  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,12 @@ 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(),
147
163
  // The FRAMEWORK Obligation↔Receipt CLOSURE link (OPTIONAL): the id of the Obligation this
148
164
  // RECEIPT closes (== the task's own requestId — the reactor mutates the task instance).
149
165
  // GENERIC, written by every impureCapability Receipt (complete OR fail), so the read
@@ -246,6 +262,18 @@ const deadLetterSchema = z.object({
246
262
  deadLetteredAt: z.number().int(),
247
263
  });
248
264
 
265
+ /**
266
+ * The RESCHEDULE payload: which obligation to judge. Deliberately carries ONLY the requestId
267
+ * — the reschedule verb reads the task's OWN committed lifecycle state (status / deadlineAt /
268
+ * attempts) through the captured-read lane and reads "now" from the deterministic captured
269
+ * clock, so a caller cannot spoof the overdue-ness verdict or the fresh timing. The provider /
270
+ * drum authority authors it; the plan decides in-law whether it is a no-op, an in-place
271
+ * re-open, or a dead-letter.
272
+ */
273
+ const rescheduleSchema = z.object({
274
+ requestId: z.string(),
275
+ });
276
+
249
277
  /**
250
278
  * The three plan builders. Each takes the task aggregate handle + the (already
251
279
  * Zod-validated) payload and returns the `PlannedOp[]` writing the envelope. A tenant
@@ -372,6 +400,29 @@ export function deadLetterOps(
372
400
  ];
373
401
  }
374
402
 
403
+ /**
404
+ * rescheduleOps — the IN-PLACE RE-OPEN half of the bounded `reschedule` verb (NOT a receipt,
405
+ * NOT terminal). Advances the timing window (`scheduledAt`/`deadlineAt`), bumps the `attempts`
406
+ * counter, and KEEPS `status:'requested'`. Because the task stays `requested` (non-terminal),
407
+ * the kernel's terminal-once gate does NOT treat this as a terminal transition — it is the one
408
+ * legal in-place mutation of a live obligation's timing (kernel-verified: mutating a
409
+ * still-`requested` task is not a receipt against a terminal obligation). Symmetrical with the
410
+ * terminal op-builders above: it writes ONLY envelope fields, bound to the task instance (#105).
411
+ */
412
+ export function rescheduleOps(
413
+ aggregateId: string,
414
+ p: { requestId: string; scheduledAt: number; deadlineAt: number; attempts: number },
415
+ ): PlannedOp[] {
416
+ const taskInstance = envelopeInstance(aggregateId, p.requestId);
417
+ return [
418
+ set(taskInstance, "scheduledAt", p.scheduledAt),
419
+ set(taskInstance, "deadlineAt", p.deadlineAt),
420
+ set(taskInstance, "attempts", p.attempts),
421
+ // KEEP status:'requested' — re-opening a non-terminal task, never a terminal transition.
422
+ set(taskInstance, "status", "requested"),
423
+ ];
424
+ }
425
+
375
426
  /**
376
427
  * The generated directive QUARTET for a impure capability. A domain dev calls
377
428
  * {@link impureCapability} ONCE and gets:
@@ -412,6 +463,15 @@ export interface ImpureCapability<
412
463
  readonly fail: RequirableDirective<z.infer<typeof failSchema>>;
413
464
  readonly block: RequirableDirective<z.infer<typeof blockSchema>>;
414
465
  readonly deadLetter: RequirableDirective<z.infer<typeof deadLetterSchema>>;
466
+ /**
467
+ * The bounded IN-LAW `reschedule` lifecycle verb (provider / drum-authority action). Its plan
468
+ * reads the task's OWN committed `{status, deadlineAt, attempts}` through the captured-read
469
+ * lane and judges overdue-ness against the deterministic captured clock, then either NO-OPs
470
+ * (terminal, or not yet overdue), RE-OPENS in place (`rescheduleOps`, bumping `attempts` +
471
+ * advancing the window, keeping `status:'requested'`), or DEAD-LETTERS when at/over the
472
+ * `maxRescheduleAttempts` cap (reusing `deadLetterOps`). Kernel-legal without any kernel change.
473
+ */
474
+ readonly reschedule: RequirableDirective<z.infer<typeof rescheduleSchema>>;
415
475
  readonly instanceId: (requestId: string) => string;
416
476
  /**
417
477
  * The CAPABILITY CREDENTIAL this task's provider must hold to fulfil Orders
@@ -485,6 +545,8 @@ export function impureCapability<
485
545
  failDirectiveId: FailId;
486
546
  blockDirectiveId: string;
487
547
  deadLetterDirectiveId: string;
548
+ /** The wire id for the bounded `reschedule` verb — caller-supplied, like the DLQ id. */
549
+ rescheduleDirectiveId: string;
488
550
  /** The task's request-param FIELDS (merged onto the envelope for the aggregate shape). */
489
551
  params: F;
490
552
  /** The task's request-param Zod SHAPE (merged onto the obligation envelope schema). */
@@ -526,6 +588,9 @@ export function impureCapability<
526
588
  scheduleAfterMs?: number;
527
589
  /** Time from acceptance until the task is overdue. Defaults to 24 hours. */
528
590
  deadlineAfterMs?: number;
591
+ /** How many times an overdue-but-still-`requested` obligation may be RE-OPENED in place by
592
+ * `reschedule` before the next reschedule dead-letters it instead. Defaults to 5. */
593
+ maxRescheduleAttempts?: number;
529
594
  }): ImpureCapability<Id, ParamsShape, F, ReceiptShape> {
530
595
  const taskAggregate = impureCapabilityAggregate(spec.aggregateId, spec.params);
531
596
  const obligationPayload = z.object(spec.paramsSchema);
@@ -549,6 +614,17 @@ export function impureCapability<
549
614
  const typedFailSchema = failSchema.extend(typedRequestId);
550
615
  const typedBlockSchema = blockSchema.extend(typedRequestId);
551
616
  const typedDeadLetterSchema = deadLetterSchema.extend(typedRequestId);
617
+ const typedRescheduleSchema = rescheduleSchema.extend(typedRequestId);
618
+ // THE BY-ID SELF-READ (the #58 captured-read lane): the reschedule plan reads the task's OWN
619
+ // committed lifecycle state by its requestId (== the instance id), keyed on the envelope's
620
+ // indexed `requestId` field. Declared here in the factory (the `tasks`/TasksByStatus query is
621
+ // keyed on `status`, so this is the by-id variant the reschedule verb needs) and DECLARED on
622
+ // the directive via `.reads(...)`; the compiler seats it in the domain automatically off that
623
+ // declaration, so it is routable even though it is not separately exported.
624
+ const taskById: QueryDecl = query(`${spec.aggregateId}ById`)
625
+ .key("requestId")
626
+ .returns(taskAggregate);
627
+ const maxRescheduleAttempts = spec.maxRescheduleAttempts ?? 5;
552
628
 
553
629
  const obligation: RequirableDirective<ObligationPayload<ParamsShape>> = directive(
554
630
  spec.obligationDirectiveId,
@@ -614,6 +690,60 @@ export function impureCapability<
614
690
  .payload(typedDeadLetterSchema)
615
691
  .plan((p) => deadLetterOps(spec.aggregateId, p));
616
692
 
693
+ // THE BOUNDED `reschedule` VERB. It `.mutates` the task and reads THAT SAME task's committed
694
+ // lifecycle state by id (the captured-read lane, declared via `.reads(taskById)`), judging
695
+ // overdue-ness IN-LAW against the deterministic captured clock — never `Date.now()`. Three
696
+ // outcomes, mirroring the consequent-outbox cap discipline (never touch a terminal task; only
697
+ // re-open genuinely-overdue work; dead-letter once the retry budget is exhausted):
698
+ // 1. status !== "requested" → NO-OP (never mutate a terminal task).
699
+ // 2. now <= deadlineAt → NO-OP (not overdue yet).
700
+ // 3. overdue & attempts < cap → RE-OPEN in place (rescheduleOps): fresh window +
701
+ // attempts+1, KEEP status:'requested' (kernel-legal —
702
+ // a still-`requested` task is non-terminal).
703
+ // 4. overdue & attempts >= cap → DEAD-LETTER (deadLetterOps, terminal) with the
704
+ // overdue reason.
705
+ const reschedule = directive(spec.rescheduleDirectiveId)
706
+ .mutates(taskAggregate)
707
+ .payload(typedRescheduleSchema)
708
+ .plan((p, ctx) => {
709
+ const now = ctx.clock().physical;
710
+ // Read the task's OWN committed state by id off the #58 captured-read lane. A framework
711
+ // plan bundled via the `@githolon/dsl` barrel reads through `readFrameworkDeclared` (the
712
+ // declared-by-construction twin of `read()`), so it never pins the tenant captured-read
713
+ // compile marker into the barrel lump; the directive DECLARES `taskById` below.
714
+ const rows = readFrameworkDeclared<Array<Record<string, unknown>>>(taskById.id, {
715
+ requestId: p.requestId,
716
+ });
717
+ const raw = Array.isArray(rows) && rows.length > 0 ? rows[0] : undefined;
718
+ // Projected rows are `{type,id,data:{…fields}}`; folded shapes may be flat — accept both.
719
+ const data = (raw && typeof raw === "object" && typeof (raw as { data?: unknown }).data === "object"
720
+ ? (raw as { data: Record<string, unknown> }).data
721
+ : raw) as Record<string, unknown> | undefined;
722
+ const status = data?.status as string | undefined;
723
+ // (1) NEVER touch a terminal task — only `requested` is non-terminal.
724
+ if (status !== "requested") return [];
725
+ const deadlineAt = Number(data?.deadlineAt ?? 0);
726
+ // (2) Not overdue yet — the deterministic clock has not passed the committed deadline.
727
+ if (now <= deadlineAt) return [];
728
+ const attempts = Number(data?.attempts ?? 0);
729
+ // (4) Overdue AND the retry budget is exhausted → terminal dead-letter (reuse deadLetterOps).
730
+ if (attempts >= maxRescheduleAttempts) {
731
+ return deadLetterOps(spec.aggregateId, {
732
+ requestId: p.requestId,
733
+ deadLetterReason: `overdue: exhausted ${maxRescheduleAttempts} reschedule attempts`,
734
+ deadLetteredAt: now,
735
+ });
736
+ }
737
+ // (3) Overdue but under the cap → RE-OPEN in place with a fresh window + bumped attempts.
738
+ return rescheduleOps(spec.aggregateId, {
739
+ requestId: p.requestId,
740
+ scheduledAt: now + (spec.scheduleAfterMs ?? 0),
741
+ deadlineAt: now + (spec.deadlineAfterMs ?? 24 * 60 * 60 * 1000),
742
+ attempts: attempts + 1,
743
+ });
744
+ })
745
+ .reads(taskById);
746
+
617
747
  return {
618
748
  aggregate: taskAggregate,
619
749
  obligation,
@@ -622,6 +752,7 @@ export function impureCapability<
622
752
  fail,
623
753
  block,
624
754
  deadLetter,
755
+ reschedule,
625
756
  instanceId: impureCapabilityInstanceId,
626
757
  // Omit-when-absent (hash discipline): a capability-free declaration carries
627
758
  // 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(),