@xemahq/kernel-contracts 0.33.0 → 0.35.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.
Files changed (77) hide show
  1. package/dist/app-runtime/index.d.ts +4 -0
  2. package/dist/app-runtime/index.d.ts.map +1 -1
  3. package/dist/app-runtime/index.js +4 -0
  4. package/dist/app-runtime/index.js.map +1 -1
  5. package/dist/app-runtime/lib/app-channel.d.ts +7 -0
  6. package/dist/app-runtime/lib/app-channel.d.ts.map +1 -0
  7. package/dist/app-runtime/lib/app-channel.js +11 -0
  8. package/dist/app-runtime/lib/app-channel.js.map +1 -0
  9. package/dist/app-runtime/lib/app-release.d.ts +24 -0
  10. package/dist/app-runtime/lib/app-release.d.ts.map +1 -0
  11. package/dist/app-runtime/lib/app-release.js +29 -0
  12. package/dist/app-runtime/lib/app-release.js.map +1 -0
  13. package/dist/app-runtime/lib/app-shell-binding.d.ts +38 -0
  14. package/dist/app-runtime/lib/app-shell-binding.d.ts.map +1 -0
  15. package/dist/app-runtime/lib/app-shell-binding.js +58 -0
  16. package/dist/app-runtime/lib/app-shell-binding.js.map +1 -0
  17. package/dist/app-runtime/lib/app.d.ts +3 -0
  18. package/dist/app-runtime/lib/app.d.ts.map +1 -1
  19. package/dist/app-runtime/lib/app.js +19 -4
  20. package/dist/app-runtime/lib/app.js.map +1 -1
  21. package/dist/app-runtime/lib/branding-config.d.ts.map +1 -1
  22. package/dist/app-runtime/lib/branding-config.js.map +1 -1
  23. package/dist/app-runtime/lib/delegated-session.js +1 -1
  24. package/dist/app-runtime/lib/delegated-session.js.map +1 -1
  25. package/dist/app-runtime/lib/surface-kind.d.ts +8 -0
  26. package/dist/app-runtime/lib/surface-kind.d.ts.map +1 -0
  27. package/dist/app-runtime/lib/surface-kind.js +12 -0
  28. package/dist/app-runtime/lib/surface-kind.js.map +1 -0
  29. package/dist/capability/index.d.ts +1 -0
  30. package/dist/capability/index.d.ts.map +1 -1
  31. package/dist/capability/index.js +1 -0
  32. package/dist/capability/index.js.map +1 -1
  33. package/dist/capability/lib/first-party-capability-domains.d.ts +29 -0
  34. package/dist/capability/lib/first-party-capability-domains.d.ts.map +1 -0
  35. package/dist/capability/lib/first-party-capability-domains.js +35 -0
  36. package/dist/capability/lib/first-party-capability-domains.js.map +1 -0
  37. package/dist/principal/index.d.ts +6 -0
  38. package/dist/principal/index.d.ts.map +1 -0
  39. package/dist/principal/index.js +22 -0
  40. package/dist/principal/index.js.map +1 -0
  41. package/dist/principal/lib/app-invocation-identity.d.ts +22 -0
  42. package/dist/principal/lib/app-invocation-identity.d.ts.map +1 -0
  43. package/dist/principal/lib/app-invocation-identity.js +36 -0
  44. package/dist/principal/lib/app-invocation-identity.js.map +1 -0
  45. package/dist/principal/lib/clamp-authority.d.ts +4 -0
  46. package/dist/principal/lib/clamp-authority.d.ts.map +1 -0
  47. package/dist/principal/lib/clamp-authority.js +23 -0
  48. package/dist/principal/lib/clamp-authority.js.map +1 -0
  49. package/dist/principal/lib/delegation-chain.d.ts +26 -0
  50. package/dist/principal/lib/delegation-chain.d.ts.map +1 -0
  51. package/dist/principal/lib/delegation-chain.js +81 -0
  52. package/dist/principal/lib/delegation-chain.js.map +1 -0
  53. package/dist/principal/lib/delegation-grant.d.ts +10 -0
  54. package/dist/principal/lib/delegation-grant.d.ts.map +1 -0
  55. package/dist/principal/lib/delegation-grant.js +12 -0
  56. package/dist/principal/lib/delegation-grant.js.map +1 -0
  57. package/dist/principal/lib/execution-principal.d.ts +20 -0
  58. package/dist/principal/lib/execution-principal.d.ts.map +1 -0
  59. package/dist/principal/lib/execution-principal.js +22 -0
  60. package/dist/principal/lib/execution-principal.js.map +1 -0
  61. package/package.json +1 -1
  62. package/src/app-runtime/index.ts +4 -0
  63. package/src/app-runtime/lib/app-channel.ts +32 -0
  64. package/src/app-runtime/lib/app-release.ts +90 -0
  65. package/src/app-runtime/lib/app-shell-binding.ts +159 -0
  66. package/src/app-runtime/lib/app.ts +48 -17
  67. package/src/app-runtime/lib/branding-config.ts +12 -6
  68. package/src/app-runtime/lib/delegated-session.ts +2 -2
  69. package/src/app-runtime/lib/surface-kind.ts +30 -0
  70. package/src/capability/index.ts +1 -0
  71. package/src/capability/lib/first-party-capability-domains.ts +76 -0
  72. package/src/principal/index.ts +5 -0
  73. package/src/principal/lib/app-invocation-identity.ts +95 -0
  74. package/src/principal/lib/clamp-authority.ts +64 -0
  75. package/src/principal/lib/delegation-chain.ts +145 -0
  76. package/src/principal/lib/delegation-grant.ts +36 -0
  77. package/src/principal/lib/execution-principal.ts +74 -0
@@ -15,16 +15,22 @@ const PRIMARY_COLOR_REGEX =
15
15
  /^(?:#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d+)\s*)?\))$/;
16
16
 
17
17
  /**
18
- * `BrandingConfig` — per-App visual branding. Used by the
19
- * public ingress (`app-runtime-api`) and any embed snippet to theme the
20
- * audience-facing UI. The shape is intentionally minimal:
18
+ * `BrandingConfig` — per-App visual branding. The shape is intentionally
19
+ * minimal:
21
20
  *
22
21
  * - `displayName` is REQUIRED — every audience-facing surface needs a
23
22
  * human-readable label; no silent fallback to the App slug.
24
23
  * - `primaryColor` is validated against a strict hex / rgb(a) grammar.
25
- * - `cssTokens` is the escape hatch for richer theming — keys map to CSS
26
- * custom-property names (without the leading `--`); the host CSS
27
- * pipeline interprets the values.
24
+ * - `cssTokens` is the escape hatch for richer theming — keys are intended to
25
+ * map to CSS custom-property names (without the leading `--`).
26
+ *
27
+ * STATUS (2026-07-10): this is a VALIDATED CONTRACT ONLY. Nothing applies it at
28
+ * runtime yet — `logoUrl` and `primaryColor` never reach the DOM, and there is
29
+ * no host CSS pipeline reading `cssTokens`. An earlier version of this docstring
30
+ * claimed the public ingress themes the audience-facing UI with it; that reader
31
+ * does not exist. The runtime branding applier (a theming provider writing CSS
32
+ * custom properties, resolved per-sub-tenant from `AppRoleBinding.tenantId`) is
33
+ * Wave 8.4 — do not assume a value set here has any visual effect until then.
28
34
  */
29
35
  export interface BrandingConfig {
30
36
  displayName: string;
@@ -13,7 +13,7 @@ import {
13
13
  * for an `ExternalSubject` audience:
14
14
  *
15
15
  * ```
16
- * sub = external-user:<externalId> (or anon:<random>)
16
+ * sub = external-subject:<externalId> (or anon:<random>)
17
17
  * act = app:<appClientId>
18
18
  * org = <orgId>
19
19
  * project = <projectId>
@@ -45,7 +45,7 @@ export interface DelegatedSessionClaims {
45
45
  export const DelegatedSessionClaimsSchema = z.object({
46
46
  sub: z.string().min(1, {
47
47
  message:
48
- 'DelegatedSessionClaims.sub must be a non-empty subject identifier (e.g. `external-user:<id>` or `anon:<random>`).',
48
+ 'DelegatedSessionClaims.sub must be a non-empty subject identifier (e.g. `external-subject:<id>` or `anon:<random>`).',
49
49
  }),
50
50
  act: z.string().min(1, {
51
51
  message:
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * `SurfaceKind` — the closed set of tiers an App surface may be authored at.
5
+ * "Surface" here is HOW an App is defined and rendered, escalating in the
6
+ * amount of hand-written code it carries:
7
+ *
8
+ * - `Declared` — the app-forge definition only, NO code. The app is a
9
+ * declarative document (audiences, biomes, capability policy, pages) that
10
+ * the runtime interprets directly.
11
+ * - `Composed` — the declarative definition PLUS App Functions: declarative
12
+ * surface extended with small authored functions, still hosted by the
13
+ * platform.
14
+ * - `Coded` — an ejected, fully hand-written service + web app that the
15
+ * team owns end-to-end; the platform hosts it but no longer authors it. Which
16
+ * server and web frameworks an ejected app uses is a scaffold decision, not a
17
+ * kernel one — this contract must never name them.
18
+ *
19
+ * NOTE: `OutputSurfaceKind` in `workflow/lib/workspace-manifest-enums.ts` is an
20
+ * UNRELATED workspace-manifest concept. Do not extend it and do not import it.
21
+ *
22
+ * Values are stable wire strings — never compare against a free-form string.
23
+ */
24
+ export enum SurfaceKind {
25
+ Declared = 'declared',
26
+ Composed = 'composed',
27
+ Coded = 'coded',
28
+ }
29
+
30
+ export const SurfaceKindSchema = z.nativeEnum(SurfaceKind);
@@ -1,5 +1,6 @@
1
1
  export * from './lib/capability-ref';
2
2
  export * from './lib/connector-capability-ref';
3
+ export * from './lib/first-party-capability-domains';
3
4
  export * from './lib/capability-grant';
4
5
  export * from './lib/capability-policy';
5
6
  export * from './lib/permission-profile';
@@ -0,0 +1,76 @@
1
+ import { z } from 'zod';
2
+
3
+ // ═══════════════════════════════════════════════════════════════════════════
4
+ // ── The CLOSED set of first-party capability DOMAINS ──
5
+ //
6
+ // A capability ref is `<domain>:<resource>.<verb>@<major>`. A DOMAIN is
7
+ // "first-party" when a fixed in-the-box Xema platform service owns every ref
8
+ // under it — i.e. the capability-router's static dispatch table resolves it
9
+ // directly (never through an org-provisioned or contributed backing).
10
+ //
11
+ // This is the SINGLE SOURCE OF TRUTH for that membership. It exists so two
12
+ // services can agree without one reaching into the other's code:
13
+ //
14
+ // - `xema-capability-router` builds its static dispatch table from EXACTLY
15
+ // these keys and asserts the two agree at boot (fail-fast on drift).
16
+ // - `capability-registry-api` REFUSES an org `CapabilityProviderBinding`
17
+ // whose ref domain is in this set — an org admin must not be able to bind
18
+ // `kb:page.read@1` / `agent:invoke@1` / `artifact:blob.read@1` to a URL
19
+ // they control and silently shadow the platform data plane.
20
+ //
21
+ // Membership is the router's dispatch keys, in dispatch-registration order.
22
+ // Adding/removing a first-party domain is a coordinated change here PLUS the
23
+ // router's dispatch table — the boot assertion turns any divergence into a
24
+ // crash rather than a silent shadow window.
25
+ // ═══════════════════════════════════════════════════════════════════════════
26
+
27
+ export const FIRST_PARTY_CAPABILITY_DOMAINS = [
28
+ 'xema-shell',
29
+ 'store',
30
+ 'biome',
31
+ 'connector',
32
+ 'kb',
33
+ 'agent',
34
+ 'memory',
35
+ 'model',
36
+ 'model-resolution',
37
+ 'mcp-tool',
38
+ 'workflow',
39
+ 'studio',
40
+ 'interactive-session',
41
+ 'agent-session',
42
+ 'document',
43
+ 'notification',
44
+ 'space',
45
+ 'mailops',
46
+ 'workspace',
47
+ 'worker-runtime',
48
+ 'artifact',
49
+ 'search',
50
+ ] as const;
51
+
52
+ export type FirstPartyCapabilityDomain =
53
+ (typeof FIRST_PARTY_CAPABILITY_DOMAINS)[number];
54
+
55
+ export const FirstPartyCapabilityDomainSchema = z.enum(
56
+ FIRST_PARTY_CAPABILITY_DOMAINS as unknown as [
57
+ FirstPartyCapabilityDomain,
58
+ ...FirstPartyCapabilityDomain[],
59
+ ],
60
+ );
61
+
62
+ const FIRST_PARTY_CAPABILITY_DOMAIN_SET: ReadonlySet<string> = new Set(
63
+ FIRST_PARTY_CAPABILITY_DOMAINS,
64
+ );
65
+
66
+ /**
67
+ * `true` iff `domain` is a first-party platform capability domain. O(1)
68
+ * membership over the closed set above. The write-path shadow refusal and the
69
+ * router's binding-skip defense-in-depth both branch on this — never on an
70
+ * ad-hoc string compare or a hand-maintained second list.
71
+ */
72
+ export function isFirstPartyCapabilityDomain(
73
+ domain: string,
74
+ ): domain is FirstPartyCapabilityDomain {
75
+ return FIRST_PARTY_CAPABILITY_DOMAIN_SET.has(domain);
76
+ }
@@ -0,0 +1,5 @@
1
+ export * from './lib/delegation-grant';
2
+ export * from './lib/delegation-chain';
3
+ export * from './lib/clamp-authority';
4
+ export * from './lib/execution-principal';
5
+ export * from './lib/app-invocation-identity';
@@ -0,0 +1,95 @@
1
+ import { z } from 'zod';
2
+ import {
3
+ SubjectKind,
4
+ SubjectRefSchema,
5
+ type SubjectRef,
6
+ } from '../../subject';
7
+
8
+ /**
9
+ * The two ways an app invocation names its `actor`.
10
+ *
11
+ * `AppInvocationIdentity` is SUGAR over the `ExecutionPrincipal.actor` field —
12
+ * it resolves to exactly one `SubjectRef` (via `resolveInvocationActor`). It
13
+ * adds no authority of its own; it only says WHOSE authority the app is
14
+ * exercising.
15
+ *
16
+ * **`AsApp` is not a bypass.** A scheduled app runs as `executor = app:<id>`
17
+ * WITH `actor = app:<id>` — the app IS the actor, acting purely as itself. It
18
+ * must hold an explicit app role IN ITS OWN app to do anything; there is no
19
+ * ambient "the scheduler said so" authority. A scheduled job does NOT skip row
20
+ * policy and does NOT skip field policy. `AsApp` narrows the app to its own
21
+ * granted authority exactly like any other principal — running unattended buys
22
+ * it nothing that running on behalf of a user would not have to earn through the
23
+ * same `clampAuthority` intersection.
24
+ */
25
+ export enum AppInvocationMode {
26
+ /** The app acts on behalf of a signed-in user; `actor` is that user. */
27
+ OnBehalfOfUser = 'on-behalf-of-user',
28
+ /** The app acts as itself (e.g. a schedule); `actor === executor === app:<id>`. */
29
+ AsApp = 'as-app',
30
+ }
31
+
32
+ export const AppInvocationModeSchema = z.nativeEnum(AppInvocationMode);
33
+
34
+ /**
35
+ * `OnBehalfOfUser` — the app is exercising a specific user's authority. `user`
36
+ * becomes `ExecutionPrincipal.actor`; the app is the `executor`.
37
+ */
38
+ export interface OnBehalfOfUserIdentity {
39
+ mode: AppInvocationMode.OnBehalfOfUser;
40
+ /** The signed-in subject whose authority the app exercises. */
41
+ user: SubjectRef;
42
+ /** The app doing the acting (becomes `executor`). */
43
+ appId: string;
44
+ }
45
+
46
+ /**
47
+ * `AsApp` — the app acts as itself. `actor` and `executor` are BOTH
48
+ * `app:<appId>`; see the `AppInvocationMode` docblock — this is not a bypass.
49
+ */
50
+ export interface AsAppIdentity {
51
+ mode: AppInvocationMode.AsApp;
52
+ /** The app acting as its own actor and executor. */
53
+ appId: string;
54
+ }
55
+
56
+ export type AppInvocationIdentity = OnBehalfOfUserIdentity | AsAppIdentity;
57
+
58
+ const onBehalfOfUserShape = z.object({
59
+ mode: z.literal(AppInvocationMode.OnBehalfOfUser),
60
+ user: SubjectRefSchema,
61
+ appId: z.string().min(1),
62
+ });
63
+
64
+ const asAppShape = z.object({
65
+ mode: z.literal(AppInvocationMode.AsApp),
66
+ appId: z.string().min(1),
67
+ });
68
+
69
+ export const OnBehalfOfUserIdentitySchema =
70
+ onBehalfOfUserShape as z.ZodType<OnBehalfOfUserIdentity>;
71
+
72
+ export const AsAppIdentitySchema = asAppShape as z.ZodType<AsAppIdentity>;
73
+
74
+ export const AppInvocationIdentitySchema = z.discriminatedUnion('mode', [
75
+ onBehalfOfUserShape,
76
+ asAppShape,
77
+ ]) as z.ZodType<AppInvocationIdentity>;
78
+
79
+ /**
80
+ * Resolve an `AppInvocationIdentity` to the `ExecutionPrincipal.actor` it is
81
+ * sugar for. `OnBehalfOfUser` → the named user. `AsApp` → the app itself
82
+ * (`app:<appId>`), which is ALSO the executor — an app acting as itself has no
83
+ * elevated authority (see the `AppInvocationMode` docblock). Total over the
84
+ * closed union; a new mode is a compile error here, never a silent default.
85
+ */
86
+ export function resolveInvocationActor(
87
+ identity: AppInvocationIdentity,
88
+ ): SubjectRef {
89
+ switch (identity.mode) {
90
+ case AppInvocationMode.OnBehalfOfUser:
91
+ return identity.user;
92
+ case AppInvocationMode.AsApp:
93
+ return { kind: SubjectKind.App, id: identity.appId };
94
+ }
95
+ }
@@ -0,0 +1,64 @@
1
+ import type { CapabilityRef } from '../../capability';
2
+
3
+ /**
4
+ * `clampAuthority` — the delegation intersection rule (plan D6).
5
+ *
6
+ * effective(P) = grants(P.executor) ∩ effective(P.actor)
7
+ *
8
+ * The effective authority of a principal is the INTERSECTION of what the
9
+ * executor is granted with what the actor already effectively holds. It is
10
+ * NEVER a union: a delegate can only ever NARROW authority, never widen it. An
11
+ * app can never do more than its caller.
12
+ *
13
+ * Structurally it cannot widen, and this is the load-bearing property: every
14
+ * element of the returned set is drawn from `executorGrants` AND must also be
15
+ * present in `actorEffective`, so the result is a subset of BOTH inputs
16
+ * (`clamp(A, B) ⊆ A` and `clamp(A, B) ⊆ B`). There is no code path that adds a
17
+ * ref present in only one operand.
18
+ *
19
+ * An EMPTY intersection is the default and correct outcome — not an error, not a
20
+ * signal to fall back to either operand. A principal whose executor shares no
21
+ * capability with its actor is authorized for nothing, and that denial is the
22
+ * whole point (it is THE test that must exist before the Wave 10 function
23
+ * sandbox: a delegate holding a capability its actor lacks is denied).
24
+ *
25
+ * Pure and side-effect free. Inputs are read-only sets of `CapabilityRef`; the
26
+ * result is a fresh set (the smaller operand is iterated for efficiency, but the
27
+ * membership rule is symmetric so the result is identical either way).
28
+ */
29
+ export function clampAuthority(
30
+ executorGrants: ReadonlySet<CapabilityRef>,
31
+ actorEffective: ReadonlySet<CapabilityRef>,
32
+ ): Set<CapabilityRef> {
33
+ const [smaller, larger] =
34
+ executorGrants.size <= actorEffective.size
35
+ ? [executorGrants, actorEffective]
36
+ : [actorEffective, executorGrants];
37
+
38
+ const intersection = new Set<CapabilityRef>();
39
+ for (const ref of smaller) {
40
+ if (larger.has(ref)) {
41
+ intersection.add(ref);
42
+ }
43
+ }
44
+ return intersection;
45
+ }
46
+
47
+ /**
48
+ * Fold `clampAuthority` down a whole chain of authority sets, in order.
49
+ * `clampAuthorityChain([grants(executor), effective(actor), …])` reduces every
50
+ * operand by intersection, so ANY empty operand collapses the whole result to
51
+ * empty. Every operand narrows; none widens. An empty input array is itself an
52
+ * empty authority set (nothing to be authorized for) — never a wildcard.
53
+ */
54
+ export function clampAuthorityChain(
55
+ authoritySets: ReadonlyArray<ReadonlySet<CapabilityRef>>,
56
+ ): Set<CapabilityRef> {
57
+ if (authoritySets.length === 0) {
58
+ return new Set<CapabilityRef>();
59
+ }
60
+ return authoritySets.reduce<Set<CapabilityRef>>(
61
+ (acc, next) => clampAuthority(acc, next),
62
+ new Set<CapabilityRef>(authoritySets[0]),
63
+ );
64
+ }
@@ -0,0 +1,145 @@
1
+ import { z } from 'zod';
2
+ import { subjectRefToString, type SubjectRef } from '../../subject';
3
+ import type { DelegationGrant } from './delegation-grant';
4
+
5
+ /**
6
+ * Maximum number of hops permitted in a single delegation chain. Delegation is
7
+ * bounded: user → app → workflow → agent is the deepest legitimate path, so a
8
+ * chain of more than THREE grants is rejected outright. This is a hard cap, not
9
+ * a truncation point — an over-long chain is an error, never silently clipped.
10
+ */
11
+ export const MAX_DELEGATION_DEPTH = 3;
12
+
13
+ /**
14
+ * Closed set of delegation-chain validation failures. Every rejection carries a
15
+ * structured code so callers branch without string parsing.
16
+ */
17
+ export enum DelegationErrorCode {
18
+ /** The chain has more hops than `MAX_DELEGATION_DEPTH`. */
19
+ ChainTooDeep = 'DELEGATION_CHAIN_TOO_DEEP',
20
+ /** A subject appears more than once across the chain — a delegation cycle. */
21
+ Cycle = 'DELEGATION_CHAIN_CYCLE',
22
+ /** Hop `i`'s `to` is not hop `i+1`'s `from` — the chain is not contiguous. */
23
+ Discontinuous = 'DELEGATION_CHAIN_DISCONTINUOUS',
24
+ /** The chain's first `from` is not the principal's `actor`. */
25
+ NotAnchoredAtActor = 'DELEGATION_CHAIN_NOT_ANCHORED_AT_ACTOR',
26
+ /** The chain's last `to` is not the principal's `executor`. */
27
+ NotAnchoredAtExecutor = 'DELEGATION_CHAIN_NOT_ANCHORED_AT_EXECUTOR',
28
+ /**
29
+ * The chain is empty but `actor` and `executor` differ. An empty chain is
30
+ * only valid for a self-acting principal (`actor === executor`, e.g. a
31
+ * scheduled `AsApp` app); claiming delegation with no hops is malformed.
32
+ */
33
+ EmptyChainMismatch = 'DELEGATION_CHAIN_EMPTY_MISMATCH',
34
+ }
35
+
36
+ export const DelegationErrorCodeSchema = z.nativeEnum(DelegationErrorCode);
37
+
38
+ /**
39
+ * Structured, fail-fast error thrown by `resolveDelegationChain`. Never a
40
+ * warning, never a coercion — an invalid chain aborts resolution.
41
+ */
42
+ export class DelegationChainError extends Error {
43
+ readonly code: DelegationErrorCode;
44
+
45
+ constructor(args: { code: DelegationErrorCode; message: string }) {
46
+ super(args.message);
47
+ this.name = 'DelegationChainError';
48
+ this.code = args.code;
49
+ }
50
+ }
51
+
52
+ function subjectsEqual(a: SubjectRef, b: SubjectRef): boolean {
53
+ return subjectRefToString(a) === subjectRefToString(b);
54
+ }
55
+
56
+ /**
57
+ * Validate a delegation chain against its principal's `actor`/`executor`
58
+ * endpoints and return the (frozen) chain when it is well-formed.
59
+ *
60
+ * Fail-fast — throws `DelegationChainError` on ANY of:
61
+ *
62
+ * - a chain longer than `MAX_DELEGATION_DEPTH` (`ChainTooDeep`);
63
+ * - a cycle: any subject appearing twice in the hop sequence (`Cycle`);
64
+ * - a chain whose first `from` is not `actor` (`NotAnchoredAtActor`);
65
+ * - a chain whose last `to` is not `executor` (`NotAnchoredAtExecutor`);
66
+ * - a non-contiguous chain where hop i's `to` ≠ hop i+1's `from`
67
+ * (`Discontinuous`);
68
+ * - an empty chain when `actor` ≠ `executor` (`EmptyChainMismatch`).
69
+ *
70
+ * Cycles and over-depth are ERRORS, never truncations: the chain is rejected
71
+ * whole, never clipped to fit.
72
+ */
73
+ export function resolveDelegationChain(args: {
74
+ actor: SubjectRef;
75
+ executor: SubjectRef;
76
+ chain: readonly DelegationGrant[];
77
+ }): readonly DelegationGrant[] {
78
+ const { actor, executor, chain } = args;
79
+
80
+ // Empty chain: only a self-acting principal (actor === executor) may omit
81
+ // delegation. Anything else is a delegation claim with no proof.
82
+ if (chain.length === 0) {
83
+ if (!subjectsEqual(actor, executor)) {
84
+ throw new DelegationChainError({
85
+ code: DelegationErrorCode.EmptyChainMismatch,
86
+ message: `Empty delegation chain requires actor === executor, but actor "${subjectRefToString(
87
+ actor,
88
+ )}" ≠ executor "${subjectRefToString(executor)}".`,
89
+ });
90
+ }
91
+ return Object.freeze([]);
92
+ }
93
+
94
+ if (chain.length > MAX_DELEGATION_DEPTH) {
95
+ throw new DelegationChainError({
96
+ code: DelegationErrorCode.ChainTooDeep,
97
+ message: `Delegation chain has ${chain.length} hops; the maximum is ${MAX_DELEGATION_DEPTH}.`,
98
+ });
99
+ }
100
+
101
+ // Anchor: first from === actor, last to === executor.
102
+ if (!subjectsEqual(chain[0]!.from, actor)) {
103
+ throw new DelegationChainError({
104
+ code: DelegationErrorCode.NotAnchoredAtActor,
105
+ message: `Delegation chain must start at actor "${subjectRefToString(
106
+ actor,
107
+ )}", but its first hop starts at "${subjectRefToString(chain[0]!.from)}".`,
108
+ });
109
+ }
110
+ const last = chain[chain.length - 1]!;
111
+ if (!subjectsEqual(last.to, executor)) {
112
+ throw new DelegationChainError({
113
+ code: DelegationErrorCode.NotAnchoredAtExecutor,
114
+ message: `Delegation chain must end at executor "${subjectRefToString(
115
+ executor,
116
+ )}", but its last hop ends at "${subjectRefToString(last.to)}".`,
117
+ });
118
+ }
119
+
120
+ // Contiguity + cycle detection over the node sequence
121
+ // [from0, to0, to1, …, toN-1].
122
+ const seen = new Set<string>();
123
+ const firstKey = subjectRefToString(chain[0]!.from);
124
+ seen.add(firstKey);
125
+ for (let i = 0; i < chain.length; i += 1) {
126
+ if (i > 0 && !subjectsEqual(chain[i - 1]!.to, chain[i]!.from)) {
127
+ throw new DelegationChainError({
128
+ code: DelegationErrorCode.Discontinuous,
129
+ message: `Delegation chain is not contiguous at hop ${i}: previous "to" "${subjectRefToString(
130
+ chain[i - 1]!.to,
131
+ )}" ≠ this "from" "${subjectRefToString(chain[i]!.from)}".`,
132
+ });
133
+ }
134
+ const toKey = subjectRefToString(chain[i]!.to);
135
+ if (seen.has(toKey)) {
136
+ throw new DelegationChainError({
137
+ code: DelegationErrorCode.Cycle,
138
+ message: `Delegation chain contains a cycle: subject "${toKey}" appears more than once.`,
139
+ });
140
+ }
141
+ seen.add(toKey);
142
+ }
143
+
144
+ return Object.freeze([...chain]);
145
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ import { SubjectRefSchema, type SubjectRef } from '../../subject';
3
+
4
+ /**
5
+ * `DelegationGrant` — one hop in a delegation chain. It records HOW authority
6
+ * moved from one subject to the next, so the PDP and the audit trail can prove
7
+ * the path an invocation took (user → app → workflow → agent → connector)
8
+ * rather than collapsing it to "the workflow service called a connector".
9
+ *
10
+ * Deliberately minimal — `{ from, to, grantedAt, reason? }` — and it does NOT
11
+ * carry a per-hop capability subset. Justification: effective authority is
12
+ * resolved by `clampAuthority` as `grants(executor) ∩ effective(actor)` from the
13
+ * authorization plane's grant tables keyed by SUBJECT, not from data embedded in
14
+ * the chain. A grant here is a PROVENANCE record (who handed authority to whom,
15
+ * when, and why), never a capability carrier — putting a capability set on the
16
+ * hop would duplicate the clamp responsibility and create a second, forgeable
17
+ * source of truth for "what may this delegate do". `grantedAt` (ISO-8601) plus
18
+ * an optional human `reason` is the whole audit payload a hop needs.
19
+ */
20
+ export interface DelegationGrant {
21
+ /** The subject handing authority DOWN at this hop. */
22
+ from: SubjectRef;
23
+ /** The subject RECEIVING authority at this hop. */
24
+ to: SubjectRef;
25
+ /** ISO-8601 timestamp at which this hop was minted. */
26
+ grantedAt: string;
27
+ /** Optional human-readable rationale for the delegation (audit only). */
28
+ reason?: string;
29
+ }
30
+
31
+ export const DelegationGrantSchema = z.object({
32
+ from: SubjectRefSchema,
33
+ to: SubjectRefSchema,
34
+ grantedAt: z.string().datetime(),
35
+ reason: z.string().min(1).optional(),
36
+ }) as z.ZodType<DelegationGrant>;
@@ -0,0 +1,74 @@
1
+ import { z } from 'zod';
2
+ import {
3
+ SubjectRefSchema,
4
+ TokenClassSchema,
5
+ type SubjectRef,
6
+ type TokenClass,
7
+ } from '../../subject';
8
+ import {
9
+ ExecutionEnvironmentRefSchema,
10
+ type ExecutionEnvironmentRef,
11
+ } from '../../execution-environment';
12
+ import { AppChannelSchema, type AppChannel } from '../../app-runtime';
13
+ import { DelegationGrantSchema, type DelegationGrant } from './delegation-grant';
14
+
15
+ /**
16
+ * `ExecutionPrincipal` — the SINGLE delegation contract (plan D5).
17
+ *
18
+ * The tree previously answered "who is acting, on whose behalf, with what
19
+ * authority" in six divergent coats: `SubjectKind`, `TokenClass`,
20
+ * `SubjectRef.actingFor`, delegated sessions, external subjects, anonymous
21
+ * sessions, and a PDP "delegation clamp". `ExecutionPrincipal` is the one
22
+ * answer. It SURVIVES the whole hop path user → app → workflow → agent →
23
+ * connector — if it did not, the audit trail would collapse to "the workflow
24
+ * service called a connector" and the PDP could not prove HOW authority was
25
+ * delegated.
26
+ *
27
+ * - `actor` is the ORIGINAL human or external subject whose authority is being
28
+ * exercised. `executor` is what is actually running (app | workflow | agent |
29
+ * service). Effective authority is `clampAuthority(grants(executor),
30
+ * effective(actor))` — an intersection, never a union.
31
+ * - `delegationChain` records every hop; `resolveDelegationChain` bounds it to
32
+ * `MAX_DELEGATION_DEPTH` and rejects cycles fail-fast.
33
+ * - `channel` (preview/live), `appId`, `releaseId`, `tenantId`, `purpose` are
34
+ * the invocation-context fields the PDP and audit trail stamp; all optional
35
+ * because not every principal is an app invocation.
36
+ */
37
+ export interface ExecutionPrincipal {
38
+ /** The original human or external subject on whose behalf execution runs. */
39
+ actor: SubjectRef;
40
+ /** What is actually running: app | workflow | agent | service. */
41
+ executor: SubjectRef;
42
+ /** Ordered record of how authority moved from `actor` to `executor`. */
43
+ delegationChain: DelegationGrant[];
44
+ /** The minted token class carried by this invocation. */
45
+ tokenClass: TokenClass;
46
+ /** The permission-plane environment this invocation is tagged with. */
47
+ environment: ExecutionEnvironmentRef;
48
+ /** The single tenant org this invocation is fenced to. */
49
+ orgId: string;
50
+ /** The app being invoked, when this is an app invocation. */
51
+ appId?: string;
52
+ /** The pinned release of `appId`, when applicable. */
53
+ releaseId?: string;
54
+ /** Which definition + data the invocation sees (preview | live). */
55
+ channel?: AppChannel;
56
+ /** Sub-tenant scope within `orgId`, when the app is multi-tenant. */
57
+ tenantId?: string;
58
+ /** Free-form audit annotation of why this invocation is happening. */
59
+ purpose?: string;
60
+ }
61
+
62
+ export const ExecutionPrincipalSchema = z.object({
63
+ actor: SubjectRefSchema,
64
+ executor: SubjectRefSchema,
65
+ delegationChain: z.array(DelegationGrantSchema),
66
+ tokenClass: TokenClassSchema,
67
+ environment: ExecutionEnvironmentRefSchema,
68
+ orgId: z.string().min(1),
69
+ appId: z.string().min(1).optional(),
70
+ releaseId: z.string().min(1).optional(),
71
+ channel: AppChannelSchema.optional(),
72
+ tenantId: z.string().min(1).optional(),
73
+ purpose: z.string().min(1).optional(),
74
+ }) as z.ZodType<ExecutionPrincipal>;