@githolon/dsl 0.106.0 → 0.107.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dart/CHANGELOG.md +38 -0
- package/dart/lib/src/runner/node-entry.mjs +6329 -5902
- package/dart/lib/src/runner/offline-install.mjs +393 -149
- package/dart/lib/src/webview_bridge.dart +662 -502
- package/dart/pubspec.yaml +1 -1
- package/dart/test/webview_bridge_test.dart +39 -2
- package/package.json +1 -1
- package/runtime/generated-application-host.js +6329 -5902
- package/src/compile_package_main.ts +2 -2
- package/src/framework/capability.ts +30 -4
- package/src/framework/domain_lifecycle.ts +8 -60
- package/src/framework/governance_relations.ts +10 -0
- package/src/framework/impure_capability.ts +69 -9
- package/src/framework/workspaces.ts +2 -1
- package/src/role.ts +13 -7
|
@@ -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.46";
|
|
1668
|
+
const NOMOS_FLUTTER_DEP = "^0.63.75";
|
|
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";
|
|
@@ -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
|
*
|
|
@@ -289,9 +300,15 @@ export function capability<
|
|
|
289
300
|
// The loop is dynamic (declared field names), so the per-field `set()` typing
|
|
290
301
|
// is asserted — the PUBLIC payload types stay fully inferred from the zod shape.
|
|
291
302
|
writeParams: (taskId, p) =>
|
|
292
|
-
Object.keys(spec.request)
|
|
293
|
-
|
|
294
|
-
|
|
303
|
+
Object.keys(spec.request)
|
|
304
|
+
// ABSENT OPTIONALS ARE SKIPPED, symmetrically with the receipt half below: an optional
|
|
305
|
+
// request field the caller omitted must author NO op — `undefined` can never be ledger
|
|
306
|
+
// state, and writing it deterministically halts the author plan (caught live 2026-08-19:
|
|
307
|
+
// the first tempo-less oblige after the tempo law refused on `estimatedMs`).
|
|
308
|
+
.filter((k) => (p as Record<string, unknown>)[k] !== undefined)
|
|
309
|
+
.map((k) =>
|
|
310
|
+
set(instance(taskAggregate, taskId), k as never, (p as Record<string, unknown>)[k] as never),
|
|
311
|
+
),
|
|
295
312
|
// The receipt half, symmetrical with the request half above: declared fields are merged
|
|
296
313
|
// onto the complete envelope as TYPED values and written straight through. The payload
|
|
297
314
|
// boundary has already validated them against the same zod the author declared, so the
|
|
@@ -315,6 +332,15 @@ export function capability<
|
|
|
315
332
|
...(spec.maxRescheduleAttempts !== undefined
|
|
316
333
|
? { maxRescheduleAttempts: spec.maxRescheduleAttempts }
|
|
317
334
|
: {}),
|
|
335
|
+
...(spec.requestRefsExist !== undefined
|
|
336
|
+
? {
|
|
337
|
+
obligationRefsExist: spec.requestRefsExist.map((pre) => ({
|
|
338
|
+
aggregate: pre.aggregate,
|
|
339
|
+
idField: pre.field,
|
|
340
|
+
rejectCode: pre.rejectCode ?? `${pre.field}-not-found`,
|
|
341
|
+
})),
|
|
342
|
+
}
|
|
343
|
+
: {}),
|
|
318
344
|
});
|
|
319
345
|
|
|
320
346
|
const camel = pascal[0]!.toLowerCase() + pascal.slice(1);
|
|
@@ -137,7 +137,7 @@ export const DomainInstallation = aggregate("DomainInstallation", {
|
|
|
137
137
|
}).public();
|
|
138
138
|
|
|
139
139
|
/**
|
|
140
|
-
* The accepted
|
|
140
|
+
* The accepted current-frontier evidence for one activation. This is deliberately an
|
|
141
141
|
* obscure framework concept: tenant models never author or name it. The generated
|
|
142
142
|
* lifecycle client runs pre-flight and folds this receipt in the SAME intent that
|
|
143
143
|
* activates the candidate, so recording evidence cannot make its own head anchor stale.
|
|
@@ -151,18 +151,7 @@ export const ReleaseReceipt = aggregate(RELEASE_RECEIPT_AGGREGATE, {
|
|
|
151
151
|
domain: t.string().merge(Lww),
|
|
152
152
|
atHash: t.gitObjectId().merge(Lww),
|
|
153
153
|
atStateRoot: t.gitObjectId().merge(Lww),
|
|
154
|
-
historyHash: t.sha256Digest().merge(Lww),
|
|
155
|
-
reportHash: t.sha256Digest().merge(Lww),
|
|
156
|
-
reportJson: t.string().merge(Lww),
|
|
157
|
-
complete: t.bool(),
|
|
158
154
|
evolveOk: t.bool(),
|
|
159
|
-
rawPromotable: t.bool(),
|
|
160
|
-
projectionEqual: t.bool(),
|
|
161
|
-
replayedIntents: t.int().merge(Lww),
|
|
162
|
-
candidateIntents: t.int().merge(Lww),
|
|
163
|
-
divergenceCount: t.int().merge(Lww),
|
|
164
|
-
baselineRoot: t.sha256Digest().merge(Lww),
|
|
165
|
-
candidateRoot: t.sha256Digest().merge(Lww),
|
|
166
155
|
acceptanceReason: t.string().merge(Lww).optional(),
|
|
167
156
|
acceptedBy: attributionField(t.string().merge(Lww)),
|
|
168
157
|
acceptedAt: t.int().merge(Lww),
|
|
@@ -176,12 +165,12 @@ function domainInstallationInstance(domainHash: Sha256Digest) {
|
|
|
176
165
|
return instance(DomainInstallation, domainInstallationAggregateId(domainHash));
|
|
177
166
|
}
|
|
178
167
|
|
|
179
|
-
export function releaseReceiptAggregateId(
|
|
180
|
-
return `${RELEASE_RECEIPT_PREFIX}${
|
|
168
|
+
export function releaseReceiptAggregateId(candidateHash: Sha256Digest, atStateRoot: string): string {
|
|
169
|
+
return `${RELEASE_RECEIPT_PREFIX}${candidateHash}:${atStateRoot}`;
|
|
181
170
|
}
|
|
182
171
|
|
|
183
|
-
function releaseReceiptInstance(
|
|
184
|
-
return instance(ReleaseReceipt, releaseReceiptAggregateId(
|
|
172
|
+
function releaseReceiptInstance(candidateHash: Sha256Digest, atStateRoot: string) {
|
|
173
|
+
return instance(ReleaseReceipt, releaseReceiptAggregateId(candidateHash, atStateRoot));
|
|
185
174
|
}
|
|
186
175
|
|
|
187
176
|
const dispositions = z.object({
|
|
@@ -198,28 +187,9 @@ const preflightReceipt = z.object({
|
|
|
198
187
|
domain: z.string().min(1),
|
|
199
188
|
atHash: gitOid,
|
|
200
189
|
atStateRoot: gitOid,
|
|
201
|
-
historyHash: lowerHex64,
|
|
202
|
-
reportHash: lowerHex64,
|
|
203
|
-
reportJson: z.string().min(2),
|
|
204
|
-
complete: z.literal(true),
|
|
205
190
|
evolveOk: z.literal(true),
|
|
206
|
-
rawPromotable: z.boolean(),
|
|
207
|
-
projectionEqual: z.boolean(),
|
|
208
|
-
replayedIntents: z.number().int().nonnegative(),
|
|
209
|
-
candidateIntents: z.number().int().nonnegative(),
|
|
210
|
-
divergenceCount: z.number().int().nonnegative(),
|
|
211
|
-
baselineRoot: lowerHex64,
|
|
212
|
-
candidateRoot: lowerHex64,
|
|
213
191
|
acceptanceReason: z.string().trim().min(1).optional(),
|
|
214
|
-
}).
|
|
215
|
-
if (!receipt.rawPromotable && receipt.acceptanceReason === undefined) {
|
|
216
|
-
ctx.addIssue({
|
|
217
|
-
code: z.ZodIssueCode.custom,
|
|
218
|
-
path: ["acceptanceReason"],
|
|
219
|
-
message: "a divergent but complete report needs an explicit business acceptance reason",
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
});
|
|
192
|
+
}).strict();
|
|
223
193
|
|
|
224
194
|
const installPackagePayload = {
|
|
225
195
|
domainHash: lowerHex64,
|
|
@@ -317,7 +287,7 @@ export const promoteDomain = directive(DOMAIN_LIFECYCLE_DIRECTIVES.Promote)
|
|
|
317
287
|
}))
|
|
318
288
|
.plan((p, ctx): PlannedOp[] => {
|
|
319
289
|
const promoted = domainInstallationInstance(p.domainHash);
|
|
320
|
-
const receipt = releaseReceiptInstance(p.preflight.
|
|
290
|
+
const receipt = releaseReceiptInstance(p.domainHash, p.preflight.atStateRoot);
|
|
321
291
|
const ops: PlannedOp[] = [
|
|
322
292
|
set(promoted, "status.phase", DomainInstallationPhase.Active),
|
|
323
293
|
set(promoted, "status.observedGeneration", 1),
|
|
@@ -326,18 +296,7 @@ export const promoteDomain = directive(DOMAIN_LIFECYCLE_DIRECTIVES.Promote)
|
|
|
326
296
|
set(receipt, "domain", p.preflight.domain),
|
|
327
297
|
set(receipt, "atHash", p.preflight.atHash),
|
|
328
298
|
set(receipt, "atStateRoot", p.preflight.atStateRoot),
|
|
329
|
-
set(receipt, "historyHash", p.preflight.historyHash),
|
|
330
|
-
set(receipt, "reportHash", p.preflight.reportHash),
|
|
331
|
-
set(receipt, "reportJson", p.preflight.reportJson),
|
|
332
|
-
set(receipt, "complete", true),
|
|
333
299
|
set(receipt, "evolveOk", true),
|
|
334
|
-
set(receipt, "rawPromotable", p.preflight.rawPromotable),
|
|
335
|
-
set(receipt, "projectionEqual", p.preflight.projectionEqual),
|
|
336
|
-
set(receipt, "replayedIntents", p.preflight.replayedIntents),
|
|
337
|
-
set(receipt, "candidateIntents", p.preflight.candidateIntents),
|
|
338
|
-
set(receipt, "divergenceCount", p.preflight.divergenceCount),
|
|
339
|
-
set(receipt, "baselineRoot", p.preflight.baselineRoot),
|
|
340
|
-
set(receipt, "candidateRoot", p.preflight.candidateRoot),
|
|
341
300
|
...(p.preflight.acceptanceReason !== undefined
|
|
342
301
|
? [set(receipt, "acceptanceReason", p.preflight.acceptanceReason)]
|
|
343
302
|
: []),
|
|
@@ -380,7 +339,7 @@ export const rollbackDomain = directive(DOMAIN_LIFECYCLE_DIRECTIVES.Rollback)
|
|
|
380
339
|
.plan((p, ctx): PlannedOp[] => {
|
|
381
340
|
const candidate = domainInstallationInstance(p.candidateHash);
|
|
382
341
|
const prior = domainInstallationInstance(p.priorHash);
|
|
383
|
-
const receipt = releaseReceiptInstance(p.preflight.
|
|
342
|
+
const receipt = releaseReceiptInstance(p.priorHash, p.preflight.atStateRoot);
|
|
384
343
|
return [
|
|
385
344
|
set(candidate, "status.phase", DomainInstallationPhase.Disabled),
|
|
386
345
|
set(candidate, "status.supersededBy", p.priorHash),
|
|
@@ -391,18 +350,7 @@ export const rollbackDomain = directive(DOMAIN_LIFECYCLE_DIRECTIVES.Rollback)
|
|
|
391
350
|
set(receipt, "domain", p.preflight.domain),
|
|
392
351
|
set(receipt, "atHash", p.preflight.atHash),
|
|
393
352
|
set(receipt, "atStateRoot", p.preflight.atStateRoot),
|
|
394
|
-
set(receipt, "historyHash", p.preflight.historyHash),
|
|
395
|
-
set(receipt, "reportHash", p.preflight.reportHash),
|
|
396
|
-
set(receipt, "reportJson", p.preflight.reportJson),
|
|
397
|
-
set(receipt, "complete", true),
|
|
398
353
|
set(receipt, "evolveOk", true),
|
|
399
|
-
set(receipt, "rawPromotable", p.preflight.rawPromotable),
|
|
400
|
-
set(receipt, "projectionEqual", p.preflight.projectionEqual),
|
|
401
|
-
set(receipt, "replayedIntents", p.preflight.replayedIntents),
|
|
402
|
-
set(receipt, "candidateIntents", p.preflight.candidateIntents),
|
|
403
|
-
set(receipt, "divergenceCount", p.preflight.divergenceCount),
|
|
404
|
-
set(receipt, "baselineRoot", p.preflight.baselineRoot),
|
|
405
|
-
set(receipt, "candidateRoot", p.preflight.candidateRoot),
|
|
406
354
|
...(p.preflight.acceptanceReason !== undefined
|
|
407
355
|
? [set(receipt, "acceptanceReason", p.preflight.acceptanceReason)]
|
|
408
356
|
: []),
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// NOMOS — canonical framework governance relation identities.
|
|
2
|
+
//
|
|
3
|
+
// These names are global, kernel-coupled vocabulary. Coin them once so DSL
|
|
4
|
+
// sugar cannot accidentally redeclare them as tenant-local relations and cause
|
|
5
|
+
// the compiler to namespace gates away from the framework's founding tuples.
|
|
6
|
+
import { relationRef } from "../relation_schema.js";
|
|
7
|
+
|
|
8
|
+
/** The global workspace authority seated at `workspace:self#admin`. */
|
|
9
|
+
export const WORKSPACE_ADMIN_RELATION = relationRef("admin");
|
|
10
|
+
|
|
@@ -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);
|
|
@@ -77,6 +77,7 @@ import { read } from "../read.js";
|
|
|
77
77
|
import { readRef, readSet, workspaceInvariant, refAs, type WorkspaceInvariantVerdict } from "./workspace_invariant.js";
|
|
78
78
|
import { writeTuple, removeTuple, RelationTuple, relationTupleInstanceId } from "./relations.js";
|
|
79
79
|
import { relationType, self, user, key } from "../relation_schema.js";
|
|
80
|
+
import { WORKSPACE_ADMIN_RELATION } from "./governance_relations.js";
|
|
80
81
|
import { GENESIS_WARRANT_ID } from "./genesis.js";
|
|
81
82
|
import { idOf } from "../value_object.js";
|
|
82
83
|
import { enrollSigner, signerObject, SIGNER_RELATION } from "./signing_identity.js";
|
|
@@ -191,7 +192,7 @@ export const workspaceRelations = relationType("workspace")
|
|
|
191
192
|
// migration. (A TENANT that wants a workspace-scoped role uses a namespaced `relationType("workspace")`
|
|
192
193
|
// + a `selfWorkspace()` seed; only THIS framework governance type is global.)
|
|
193
194
|
.asGlobal()
|
|
194
|
-
.relation(
|
|
195
|
+
.relation(WORKSPACE_ADMIN_RELATION, { rewrite: self(), allow: [user(), key()] })
|
|
195
196
|
.relation("creator", { rewrite: self(), allow: [user(), key()] })
|
|
196
197
|
.relation("platformCreator", { rewrite: self(), allow: [user(), key()] });
|
|
197
198
|
|
package/src/role.ts
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
* `relationType("workspace")` block (the fail-open footgun is CLOSED — a `.requires(role.edit)`
|
|
15
15
|
* always finds a declared relation), and the `<role>View` relation is a READ-VISIBILITY cap
|
|
16
16
|
* (used as an aggregate `requires(role.view)` — usd.ts synthesizes the global workspace read
|
|
17
|
-
* relation, exactly as any private aggregate does).
|
|
18
|
-
* default `admin`
|
|
17
|
+
* relation, exactly as any private aggregate does). A custom grant-authority relation is likewise
|
|
18
|
+
* authored locally. The default `admin` instead refers to the framework's existing GLOBAL
|
|
19
|
+
* `workspace:self#admin`; redeclaring it locally would namespace the gate away from its founding tuple.
|
|
19
20
|
*
|
|
20
21
|
* 2. GRANT / SEED DIRECTIVES per role — `grant<Role>` / `revoke<Role>` fold the relation TUPLE via
|
|
21
22
|
* the existing `writeTuple`/`removeTuple` primitives (a grant seats `<role>Edit` + `<role>View`
|
|
@@ -41,6 +42,7 @@ import type { AggregateHandle } from "./aggregate.js";
|
|
|
41
42
|
import type { Directive, RequirableDirective } from "./directive.js";
|
|
42
43
|
import { directive } from "./directive.js";
|
|
43
44
|
import { RelationTuple, writeTuple, removeTuple } from "./framework/relations.js";
|
|
45
|
+
import { WORKSPACE_ADMIN_RELATION } from "./framework/governance_relations.js";
|
|
44
46
|
import { relationType, self, user, key, type RelationTypeDecl } from "./relation_schema.js";
|
|
45
47
|
|
|
46
48
|
/** A role name: kebab-case (`editor`, `site-admin`) — lowered to relation/directive labels. */
|
|
@@ -234,7 +236,7 @@ export function role(name: string): RoleBuilder {
|
|
|
234
236
|
name,
|
|
235
237
|
editRelation: `${base}Edit`,
|
|
236
238
|
viewRelation: `${base}View`,
|
|
237
|
-
grantableBy:
|
|
239
|
+
grantableBy: WORKSPACE_ADMIN_RELATION,
|
|
238
240
|
editAggregates: [],
|
|
239
241
|
viewAggregates: [],
|
|
240
242
|
canEdit: false,
|
|
@@ -256,8 +258,10 @@ const ROLE_RELATIONS_KEY = "__nomosRoleRelations";
|
|
|
256
258
|
* EXPANDER (the `capability()`/birth-meter seam): given a module's exports bag, LIFT every `RoleEmission`
|
|
257
259
|
* into discoverable entries — the grant/revoke/seed directives become top-level exports (so `directivesOf`
|
|
258
260
|
* finds them), and ONE synthesized `relationType("workspace")` (the `<role>Edit` write relations + the
|
|
259
|
-
* distinct `grantableBy` grant-authority relations, deduped) becomes a discoverable relation type
|
|
260
|
-
* `.requires(role.edit)` / `.requires(
|
|
261
|
+
* distinct LOCAL `grantableBy` grant-authority relations, deduped) becomes a discoverable relation type
|
|
262
|
+
* (so `.requires(role.edit)` / `.requires(customGrantAuthority)` have TEETH — the fail-open footgun is
|
|
263
|
+
* closed). The default framework `admin` authority is already declared by the global governance schema
|
|
264
|
+
* and MUST NOT be redeclared here as tenant-local. The
|
|
261
265
|
* `<role>View` read relations are NOT authored here: they are read-visibility caps, synthesized by usd.ts
|
|
262
266
|
* from the aggregates that declare `requires(role.view)`. IDEMPOTENT + additive: a bag with no role is
|
|
263
267
|
* returned unchanged (byte-identical), and re-running on an already-expanded bag is a no-op.
|
|
@@ -281,12 +285,14 @@ export function expandRoleExports(mod: Record<string, unknown>): Record<string,
|
|
|
281
285
|
};
|
|
282
286
|
for (const e of emissions) {
|
|
283
287
|
if (e.meta.editRelation) declare(e.meta.editRelation);
|
|
284
|
-
declare(e.meta.grantableBy);
|
|
288
|
+
if (e.meta.grantableBy !== WORKSPACE_ADMIN_RELATION) declare(e.meta.grantableBy);
|
|
285
289
|
for (const dir of e.directives) {
|
|
286
290
|
const anyDir = dir as { id: string };
|
|
287
291
|
out[`__role_${anyDir.id}`] = dir;
|
|
288
292
|
}
|
|
289
293
|
}
|
|
290
|
-
|
|
294
|
+
// Keep the marker even for a view-only role using global admin so expansion stays idempotent without
|
|
295
|
+
// publishing an empty relation schema declaration.
|
|
296
|
+
out[ROLE_RELATIONS_KEY] = declared.size > 0 ? builder as unknown as RelationTypeDecl : true;
|
|
291
297
|
return out;
|
|
292
298
|
}
|