@githolon/dsl 0.106.0 → 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/package.json
CHANGED
|
@@ -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";
|
|
@@ -218,6 +218,17 @@ export function capability<
|
|
|
218
218
|
/** How many times an overdue-but-still-`requested` obligation may be RE-OPENED in place by
|
|
219
219
|
* the bounded `reschedule` verb before the next reschedule dead-letters it. Defaults to 5. */
|
|
220
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
|
+
}>;
|
|
221
232
|
/**
|
|
222
233
|
* THE ACKNOWLEDGED WAIVER — open this capability's Receipts to any authorised author.
|
|
223
234
|
*
|
|
@@ -315,6 +326,15 @@ export function capability<
|
|
|
315
326
|
...(spec.maxRescheduleAttempts !== undefined
|
|
316
327
|
? { maxRescheduleAttempts: spec.maxRescheduleAttempts }
|
|
317
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
|
+
: {}),
|
|
318
338
|
});
|
|
319
339
|
|
|
320
340
|
const camel = pascal[0]!.toLowerCase() + pascal.slice(1);
|
|
@@ -160,6 +160,13 @@ export const IMPURE_CAPABILITY_ENVELOPE = {
|
|
|
160
160
|
// (read as 0); making it required would break decode of every pre-existing task AND the era
|
|
161
161
|
// rule (old chains fold no `attempts`). Judged against `maxRescheduleAttempts` to bound retries.
|
|
162
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(),
|
|
163
170
|
// The FRAMEWORK Obligation↔Receipt CLOSURE link (OPTIONAL): the id of the Obligation this
|
|
164
171
|
// RECEIPT closes (== the task's own requestId — the reactor mutates the task instance).
|
|
165
172
|
// GENERIC, written by every impureCapability Receipt (complete OR fail), so the read
|
|
@@ -411,7 +418,14 @@ export function deadLetterOps(
|
|
|
411
418
|
*/
|
|
412
419
|
export function rescheduleOps(
|
|
413
420
|
aggregateId: string,
|
|
414
|
-
p: {
|
|
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
|
+
},
|
|
415
429
|
): PlannedOp[] {
|
|
416
430
|
const taskInstance = envelopeInstance(aggregateId, p.requestId);
|
|
417
431
|
return [
|
|
@@ -420,6 +434,12 @@ export function rescheduleOps(
|
|
|
420
434
|
set(taskInstance, "attempts", p.attempts),
|
|
421
435
|
// KEEP status:'requested' — re-opening a non-terminal task, never a terminal transition.
|
|
422
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
|
+
: []),
|
|
423
443
|
];
|
|
424
444
|
}
|
|
425
445
|
|
|
@@ -591,6 +611,19 @@ export function impureCapability<
|
|
|
591
611
|
/** How many times an overdue-but-still-`requested` obligation may be RE-OPENED in place by
|
|
592
612
|
* `reschedule` before the next reschedule dead-letters it instead. Defaults to 5. */
|
|
593
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
|
+
}>;
|
|
594
627
|
}): ImpureCapability<Id, ParamsShape, F, ReceiptShape> {
|
|
595
628
|
const taskAggregate = impureCapabilityAggregate(spec.aggregateId, spec.params);
|
|
596
629
|
const obligationPayload = z.object(spec.paramsSchema);
|
|
@@ -626,12 +659,25 @@ export function impureCapability<
|
|
|
626
659
|
.returns(taskAggregate);
|
|
627
660
|
const maxRescheduleAttempts = spec.maxRescheduleAttempts ?? 5;
|
|
628
661
|
|
|
629
|
-
|
|
662
|
+
let obligationBuilder = directive(
|
|
630
663
|
spec.obligationDirectiveId,
|
|
631
664
|
)
|
|
632
665
|
.creates(taskAggregate)
|
|
633
|
-
.payload(obligationPayload)
|
|
634
|
-
|
|
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) => {
|
|
635
681
|
// The caller supplies BUSINESS PARAMS only. Nomos owns the task identity,
|
|
636
682
|
// provenance and lifecycle clock so generated clients cannot accidentally
|
|
637
683
|
// spoof or hand-roll the obligation envelope.
|
|
@@ -654,6 +700,7 @@ export function impureCapability<
|
|
|
654
700
|
.set("status", "requested");
|
|
655
701
|
return spec.writeParams(impureCapabilityInstanceId(requestId), params);
|
|
656
702
|
});
|
|
703
|
+
const obligation = obligationDirective as RequirableDirective<ObligationPayload<ParamsShape>>;
|
|
657
704
|
|
|
658
705
|
// The two branches above are distinct object schemas; the declared payload TYPE is the
|
|
659
706
|
// `CompletePayload<ReceiptShape>` conditional, so name it once here rather than leaking a
|
|
@@ -696,7 +743,7 @@ export function impureCapability<
|
|
|
696
743
|
// outcomes, mirroring the consequent-outbox cap discipline (never touch a terminal task; only
|
|
697
744
|
// re-open genuinely-overdue work; dead-letter once the retry budget is exhausted):
|
|
698
745
|
// 1. status !== "requested" → NO-OP (never mutate a terminal task).
|
|
699
|
-
// 2. now <= deadlineAt
|
|
746
|
+
// 2. now <= deadlineAt AND no declared-limit breach → NO-OP (not overdue yet).
|
|
700
747
|
// 3. overdue & attempts < cap → RE-OPEN in place (rescheduleOps): fresh window +
|
|
701
748
|
// attempts+1, KEEP status:'requested' (kernel-legal —
|
|
702
749
|
// a still-`requested` task is non-terminal).
|
|
@@ -723,23 +770,36 @@ export function impureCapability<
|
|
|
723
770
|
// (1) NEVER touch a terminal task — only `requested` is non-terminal.
|
|
724
771
|
if (status !== "requested") return [];
|
|
725
772
|
const deadlineAt = Number(data?.deadlineAt ?? 0);
|
|
726
|
-
//
|
|
727
|
-
|
|
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 [];
|
|
728
784
|
const attempts = Number(data?.attempts ?? 0);
|
|
729
785
|
// (4) Overdue AND the retry budget is exhausted → terminal dead-letter (reuse deadLetterOps).
|
|
730
786
|
if (attempts >= maxRescheduleAttempts) {
|
|
731
787
|
return deadLetterOps(spec.aggregateId, {
|
|
732
788
|
requestId: p.requestId,
|
|
733
|
-
deadLetterReason:
|
|
789
|
+
deadLetterReason: pastLimit
|
|
790
|
+
? `overdue: exhausted ${maxRescheduleAttempts} reschedule attempts (declared limit ${limitMs}ms breached; elapsed ${elapsedMs}ms)`
|
|
791
|
+
: `overdue: exhausted ${maxRescheduleAttempts} reschedule attempts`,
|
|
734
792
|
deadLetteredAt: now,
|
|
735
793
|
});
|
|
736
794
|
}
|
|
737
|
-
// (3) Overdue but under the cap → RE-OPEN in place with a fresh
|
|
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.
|
|
738
797
|
return rescheduleOps(spec.aggregateId, {
|
|
739
798
|
requestId: p.requestId,
|
|
740
799
|
scheduledAt: now + (spec.scheduleAfterMs ?? 0),
|
|
741
800
|
deadlineAt: now + (spec.deadlineAfterMs ?? 24 * 60 * 60 * 1000),
|
|
742
801
|
attempts: attempts + 1,
|
|
802
|
+
...(pastLimit ? { limitBreach: { at: now, elapsedMs } } : {}),
|
|
743
803
|
});
|
|
744
804
|
})
|
|
745
805
|
.reads(taskById);
|