@openwop/openwop-conformance 1.54.0 → 1.58.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 (29) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -2
  3. package/coverage.md +2 -0
  4. package/package.json +1 -1
  5. package/schemas/agent-manifest.schema.json +23 -1
  6. package/schemas/capabilities.schema.json +62 -3
  7. package/schemas/connection-pack-manifest.schema.json +5 -0
  8. package/schemas/frontend-plugin-manifest.schema.json +9 -3
  9. package/schemas/run-snapshot.schema.json +6 -1
  10. package/schemas/workflow-chain-pack-manifest.schema.json +89 -6
  11. package/schemas/workflow-definition.schema.json +4 -3
  12. package/src/lib/anonymousActor.ts +99 -0
  13. package/src/lib/workflow-chain-expansion.ts +342 -0
  14. package/src/scenarios/agent-manifest-role-profile.test.ts +116 -0
  15. package/src/scenarios/anonymous-actor-audit-opaque.test.ts +71 -0
  16. package/src/scenarios/anonymous-actor-default-deny.test.ts +87 -0
  17. package/src/scenarios/anonymous-actor-egress-guarded.test.ts +54 -0
  18. package/src/scenarios/anonymous-actor-no-secret-reach.test.ts +78 -0
  19. package/src/scenarios/anonymous-actor-shape.test.ts +173 -0
  20. package/src/scenarios/anonymous-actor-write-gated.test.ts +83 -0
  21. package/src/scenarios/chain-produced-var-roundtrip.test.ts +152 -0
  22. package/src/scenarios/chain-subchain-cycle-rejected.test.ts +141 -0
  23. package/src/scenarios/chain-subchain-fanout.test.ts +139 -0
  24. package/src/scenarios/chain-subchain-sibling.test.ts +147 -0
  25. package/src/scenarios/chain-subchain-unsupported-refused.test.ts +66 -0
  26. package/src/scenarios/connection-pack-manifest-valid.test.ts +33 -0
  27. package/src/scenarios/edge-condition-truthy-falsy.test.ts +104 -0
  28. package/src/scenarios/frontend-plugin-packs.test.ts +24 -0
  29. package/src/scenarios/workflow-chain-internal-flag.test.ts +80 -0
@@ -504,6 +504,47 @@
504
504
  }
505
505
  }
506
506
  },
507
+ "anonymousActor": {
508
+ "type": "object",
509
+ "additionalProperties": false,
510
+ "description": "RFC 0132 (`Active`). Anonymous-actor authorization for public agent surfaces — an opaque, origin-bound, ephemeral, non-cross-linkable, non-PII principal (`run-snapshot.owner.principalKind: \"anonymous\"`) whose authority is a DEFAULT-DENY, explicit per-surface tool grant, never the default-on tool baseline (§C.1). Two tiers: `read` (tenant-scoped, no egress, no secret/BYOK reach — §C.2) and `bounded-write-egress` (writes/egress permitted ONLY behind a mandatory HITL/approval gate (RFC 0051) OR a hard rate-limit + per-session cap, over the RFC 0076 §B / RFC 0079 SSRF-guarded audience-bound egress path, never attaching a tenant credential out-of-audience — §C.3). Every anon tool call emits `authorization.decided` (RFC 0049) attributable to the opaque session id, carrying no PII/credential (§D). The block is OMITTED ENTIRELY when unsupported (const-true `supported`, `dataResidency`-style — never `supported: false`). Absent ⇒ the host runs no tool-enabled public surface under RFC 0132 (e.g. a no-tools single-turn public gateway needs no advertisement).",
511
+ "required": ["supported", "tiers"],
512
+ "properties": {
513
+ "supported": {
514
+ "const": true,
515
+ "description": "RFC 0132. Present-and-true when the host honors anonymous-actor authorization per §C. The block is omitted entirely when the host runs no tool-enabled public surface (never `supported: false`)."
516
+ },
517
+ "tiers": {
518
+ "type": "array",
519
+ "minItems": 1,
520
+ "uniqueItems": true,
521
+ "items": { "enum": ["read", "bounded-write-egress"] },
522
+ "description": "RFC 0132 §B. The anon capability tiers this host BEHAVIORALLY honors (truthful-advertisement — a tier the host does not enforce per §C is a dishonest claim; `OPENWOP_REQUIRE_BEHAVIOR=true` MUST fail it). `read`: tenant-scoped reads, no egress, no secrets (§C.2). `bounded-write-egress`: writes/egress ONLY behind a §C.3 control."
523
+ },
524
+ "writeEgressControls": {
525
+ "type": "array",
526
+ "minItems": 1,
527
+ "uniqueItems": true,
528
+ "items": { "enum": ["hitl", "rate-limit-session-cap"] },
529
+ "description": "RFC 0132 §B. REQUIRED and non-empty IFF `bounded-write-egress` ∈ `tiers`; MUST be absent otherwise. The mandatory control(s) the host enforces before an anon write/egress: `hitl` = a per-action approval gate (RFC 0051 — the action suspends pending a human decision); `rate-limit-session-cap` = a hard per-IP/per-window rate limit AND a per-session action cap. A `bounded-write-egress` tier with no advertised control is the fail-open shape §B.2 forbids."
530
+ },
531
+ "failClosed": {
532
+ "const": true,
533
+ "description": "RFC 0132. An anon tool call whose grant is absent, unresolvable, or errors MUST deny (never default-allow). There is no fail-open anonymous mode. Mirrors `capabilities.authorization.failClosed`."
534
+ }
535
+ },
536
+ "allOf": [
537
+ {
538
+ "$comment": "RFC 0132 §B.2 — writeEgressControls is REQUIRED and non-empty when bounded-write-egress is advertised, and MUST be absent otherwise (a control-less write/egress tier is the fail-open shape this RFC forbids).",
539
+ "if": {
540
+ "properties": { "tiers": { "contains": { "const": "bounded-write-egress" } } },
541
+ "required": ["tiers"]
542
+ },
543
+ "then": { "required": ["writeEgressControls"] },
544
+ "else": { "not": { "required": ["writeEgressControls"] } }
545
+ }
546
+ ]
547
+ },
507
548
  "credentials": {
508
549
  "type": "object",
509
550
  "description": "RFC 0046 (`Draft`). Portable credential resolution + lifecycle contract — sibling to `secrets`, first-class store-at-rest + workspace sharing + two-key-overlap rotation. A pack references a credential by `{ ref, scope }` (see `credential-reference.schema.json`); the host resolves it into the node sandbox ONLY — never into inputs, persisted variables, channels, any run.* event payload, the debug bundle, or replay state (SECURITY invariant `credential-payload-redaction`). Supersedes the informal BYOK annex; the `secrets` advertisement stays valid.",
@@ -1811,14 +1852,14 @@
1811
1852
  },
1812
1853
  "surfaces": {
1813
1854
  "type": "array",
1814
- "description": "Plugin surfaces this host renders. A pack's `uiPlugins[].surface` not in this set is installable-but-inert (§Degradation).",
1815
- "items": { "type": "string", "enum": ["artifact-viewer", "route", "settings-panel"] },
1855
+ "description": "Plugin surfaces this host renders. A pack's `uiPlugins[].surface` not in this set is installable-but-inert (§Degradation). `canvas-preview` (RFC 0130) mounts inside a host-owned canvas editor.",
1856
+ "items": { "type": "string", "enum": ["artifact-viewer", "route", "settings-panel", "canvas-preview"] },
1816
1857
  "uniqueItems": true
1817
1858
  },
1818
1859
  "hostApi": {
1819
1860
  "type": "array",
1820
1861
  "description": "The `ui-plugin/1` host-RPC methods this host honors. A plugin call to a method not in this set (regardless of the plugin's declared `hostApi`) MUST be rejected with `method_not_allowed` (`frontend-plugin-rpc-allowlist`). A host advertising `artifact.write` MUST enforce the `version`-token optimistic concurrency (RFC 0117 §Concurrency).",
1821
- "items": { "type": "string", "enum": ["artifact.read", "artifact.write", "host.toast", "host.navigate"] },
1862
+ "items": { "type": "string", "enum": ["artifact.read", "artifact.write", "host.toast", "host.navigate", "host.announce"] },
1822
1863
  "uniqueItems": true
1823
1864
  },
1824
1865
  "maxEntryBytes": { "type": "integer", "minimum": 1, "description": "Per-plugin entry-bundle byte ceiling the host will load." }
@@ -1919,6 +1960,24 @@
1919
1960
  "hostExpansionSeam": {
1920
1961
  "type": "boolean",
1921
1962
  "description": "RFC 0013 erratum (2026-07-05). OPTIONAL. A **conformance-only test seam** advertisement (category: test harness, cf. `observability.testSeams` — NOT a product capability): when `true`, the host serves `POST /v1/host/sample/workflow-chain:expand` returning the `vendor.openwop.workflow-chain-sample` v1.0.0 expansion that `conformance/src/scenarios/workflow-chain-host-expansion.test.ts` asserts against. Absent/`false` ⇒ that live-host expansion scenario soft-skips; the semantic `workflowChainPacks.supported` claim is witnessed by the server-free `workflow-chain-expansion.test.ts` legs. This flag is DISTINCT from `deferredParameters` (RFC 0124), which is witnessed through its own `POST /v1/host/sample/chain/deferred-expand` seam — so a host MAY advertise `supported` / `deferredParameters.supported` without standing up the RFC 0013 sample-pack seam it was never handed a published fixture for. Advertising `supported:true` no longer conscripts a host into the RFC 0013 host-expansion scenario."
1963
+ },
1964
+ "subChains": {
1965
+ "type": "object",
1966
+ "description": "RFC 0133 (workflow-chain composition, `Accepted`). OPTIONAL. When `supported: true`, the host's `POST …/workflows/from-chain` implements RUNTIME sub-chain composition per `workflow-chain-packs.md` §\"Sub-chain composition (RFC 0133)\": for each `config.subChainRef` reachable from a parent chain, it resolves the referenced chain (sibling or external), recursively expands + co-registers it as its own owned workflow (deterministic TENANT-SCOPED id from `(tenantId, childChainId, version)` — two tenants never collide on the global registry, a shared child registers once across parents in a tenant, a repeat instantiation converges), rewrites the referencing node's `subChainRef` → the minted child `config.workflowId`, and dispatches the child as a child run (`core.subWorkflow` / `core.dispatch` child-run); the `from-chain` response carries `{ workflowId, subChainWorkflowIds[], nodeCount }`. Bounded by a cycle check (`sub_chain_cycle`) + a depth cap (`sub_chain_max_depth_exceeded`). A host that does NOT advertise this block MUST refuse a `subChains`-bearing chain at author/instantiate time with `sub_chain_unsupported` (422) — it never silently flattens. `producedVariables` (RFC 0133 §2) needs NO flag: it is pure variable emission under the base `workflowChainPacks.supported`. Conformance scenarios `chain-subchain-fanout.test.ts` + `chain-subchain-unsupported-refused.test.ts` gate on this flag; the server-free `chain-subchain-sibling` / `chain-subchain-cycle-rejected` / `chain-produced-var-roundtrip` legs run unconditionally against the reference library.",
1967
+ "properties": {
1968
+ "supported": {
1969
+ "type": "boolean",
1970
+ "description": "Whether the host implements runtime sub-chain co-registration + child dispatch. `false` (or omission of this block) signals the host does NOT compose sub-chains and MUST refuse a `subChains`-bearing chain with `sub_chain_unsupported`."
1971
+ },
1972
+ "maxDepth": {
1973
+ "type": "integer",
1974
+ "minimum": 1,
1975
+ "default": 8,
1976
+ "description": "RFC 0133 §1.3 (resolves UQ3). RECOMMENDED default 8. The maximum sub-chain nesting depth the host co-expands; exceeding it fails closed with the DISTINCT code `sub_chain_max_depth_exceeded` (the DoS depth backstop, alongside the `sub_chain_cycle` self-composition check — SECURITY `sub-chain-expansion-bounded`)."
1977
+ }
1978
+ },
1979
+ "required": ["supported"],
1980
+ "additionalProperties": false
1922
1981
  }
1923
1982
  },
1924
1983
  "required": ["supported"],
@@ -65,6 +65,11 @@
65
65
  "type": "string",
66
66
  "enum": ["communication", "docs", "crm", "dev", "storage", "email-calendar", "ticketing", "data-warehouse", "marketing", "finance", "hr", "esignature", "support", "project-management", "payments", "other"]
67
67
  },
68
+ "vendor": {
69
+ "type": "string",
70
+ "minLength": 1,
71
+ "description": "RFC 0123. OPTIONAL. The commercial vendor / ecosystem this connector belongs to (e.g. \"Microsoft 365\", \"Google\", \"Workday\"). Presentational grouping ONLY — it is NOT the resolution key (RFC 0047 `provider` still resolves auth). Free-form (not an enum) so a new vendor needs no schema change; a host/registry groups connectors sharing a `vendor` under one heading and MUST fall back to the provider `displayName` when it is absent."
72
+ },
68
73
  "auth": {
69
74
  "type": "object",
70
75
  "required": ["kind"],
@@ -64,8 +64,8 @@
64
64
  },
65
65
  "surface": {
66
66
  "type": "string",
67
- "description": "Which host surface this plugin renders into. `artifact-viewer` renders a typed artifact (RFC 0071) in a sandbox; `route` mounts a standalone admin/tool page; `settings-panel` contributes a configuration panel. A host degrades a `surface` it does not advertise to nothing (§Degradation).",
68
- "enum": ["artifact-viewer", "route", "settings-panel"]
67
+ "description": "Which host surface this plugin renders into. `artifact-viewer` renders a typed artifact (RFC 0071) in a sandbox; `route` mounts a standalone admin/tool page; `settings-panel` contributes a configuration panel; `canvas-preview` renders the live document preview inside a host-owned canvas editor (RFC 0130). A host degrades a `surface` it does not advertise to nothing (§Degradation).",
68
+ "enum": ["artifact-viewer", "route", "settings-panel", "canvas-preview"]
69
69
  },
70
70
  "entry": {
71
71
  "type": "string",
@@ -77,10 +77,16 @@
77
77
  "description": "The closed allowlist of `ui-plugin/1` host-RPC methods this plugin is permitted to call. The host MUST reject (with an `error` response, never silent execution) any method not in BOTH this declared list AND the host's `capabilities.uiPlugins.hostApi` set (`frontend-plugin-rpc-allowlist`). The plugin holds no credentials; every method is host-mediated and authz-checked.",
78
78
  "items": {
79
79
  "type": "string",
80
- "enum": ["artifact.read", "artifact.write", "host.toast", "host.navigate"]
80
+ "enum": ["artifact.read", "artifact.write", "host.toast", "host.navigate", "host.announce"]
81
81
  },
82
82
  "uniqueItems": true
83
83
  },
84
+ "canvasTypes": {
85
+ "type": "array",
86
+ "description": "OPTIONAL; meaningful only for `surface: \"canvas-preview\"` (RFC 0130): the host canvas type id(s) this preview renders. A host mounts the plugin only for matching canvas documents and MUST ignore entries it does not recognize; absent/empty means never auto-mounted.",
87
+ "items": { "type": "string", "maxLength": 128 },
88
+ "uniqueItems": true
89
+ },
84
90
  "connectSrc": {
85
91
  "type": "array",
86
92
  "description": "OPTIONAL explicit `connect-src` CSP exceptions the plugin needs (e.g., a font CDN). Absent → the host serves the plugin under a DENY-EGRESS CSP (`frontend-plugin-egress`): no network egress beyond the host-RPC channel. Any entry here is part of the front-end-review checkpoint (`registry-operations.md`) a reviewer audits; a host MAY refuse a pack whose exceptions it will not grant.",
@@ -40,7 +40,12 @@
40
40
  "properties": {
41
41
  "tenant": { "type": "string", "minLength": 1, "description": "Top-level isolation boundary." },
42
42
  "workspace": { "type": "string", "minLength": 1, "description": "Optional sub-tenant within the tenant (RFC 0048 workspace)." },
43
- "principal": { "type": "string", "minLength": 1, "description": "Acting identity (user or agent) — opaque id, never PII." }
43
+ "principal": { "type": "string", "minLength": 1, "description": "Acting identity (user or agent) — opaque id, never PII." },
44
+ "principalKind": {
45
+ "type": "string",
46
+ "enum": ["user", "agent", "anonymous"],
47
+ "description": "RFC 0132. The kind of the acting principal. OPTIONAL and EXPLICIT — absent ⇒ unconstrained (today's RFC 0048 behavior; a host that does not distinguish kinds omits it). `anonymous` ⇒ the RFC 0132 §A anonymous-actor rules and the §C default-deny per-surface grant bind: the `principal` id MUST be opaque, origin-bound, ephemeral, non-cross-linkable, non-PII, and never inherit a role or a default-on tool baseline. A host that advertises `capabilities.anonymousActor` and dispatches through a public surface MUST set `anonymous` on the resulting run. Resolves RFC 0048 §Unresolved-Q1 for the anonymous case."
48
+ }
44
49
  },
45
50
  "additionalProperties": false
46
51
  },
@@ -86,6 +86,10 @@
86
86
  "type": "string",
87
87
  "description": "One-paragraph description of what the chain produces. Surfaced in host editor tile hover-text."
88
88
  },
89
+ "internal": {
90
+ "type": "boolean",
91
+ "description": "RFC 0135 — when true, this chain is a composition-only fragment (typically an RFC 0133 sub-chain child): hosts MUST omit it from default template-gallery/picker listings while keeping it loadable, resolvable by id, composable, and expandable exactly like any other chain. Presentational/discovery semantics only — NOT an authorization boundary (a host MUST NOT treat it as access control). Absent ⇒ false (listed). See workflow-chain-packs.md §\"Chain visibility (RFC 0135)\"."
92
+ },
89
93
  "parameters": {
90
94
  "type": "object",
91
95
  "description": "JSON Schema 2020-12 fragment describing the parameter values the host editor MUST collect from the author at drop time. Authors-supplied values are validated against this schema before expansion proceeds; invalid input MUST be rejected with `chain_parameter_invalid`. RECOGNIZED EXTENSION KEY (RFC 0124 / WCP4): a property MAY carry `x-openwop-sensitive: true` to declare the parameter secret-class. A host that recognizes it MUST NOT expansion-time-freeze that parameter (plaintext secret-at-rest leak, SR-1) and MUST NOT materialize it as a plaintext `source:\"variable\"` binding (the value would land in the run-scoped bag / `RunSnapshot.variables` — the same SR-1 leak one layer down). Instead, per the 2026-07-04 §Security amendment: it is deferrable ONLY in a prompt-body position, where it MUST be materialized as a `source:\"secret\"` `PromptVariable` (BYOK-resolved via `capabilities.secrets`, redacted to `[REDACTED:<secretId>]` in `prompt.composed`, never bagged); in ANY other position (whole-value `node.inputs`, embedded non-prompt `config`, or a host lacking deferred / `secrets` support) it MUST fail closed with `sensitive_param_not_deferrable` (422). Per-run supply is a `credentialRef` secret reference, never plaintext. See `workflow-chain-packs.md` §\"Deferred-parameter expansion\" / RFC 0124 §Security.",
@@ -106,6 +110,76 @@
106
110
  },
107
111
  "uniqueItems": true,
108
112
  "description": "Capability traits to propagate to every expanded node. Hosts MUST copy this array into each expanded `WorkflowNode.capabilities` so existing capability gates apply uniformly."
113
+ },
114
+ "subChains": {
115
+ "type": "array",
116
+ "items": { "$ref": "#/$defs/SubChainRef" },
117
+ "description": "RFC 0133 §1.1. OPTIONAL. Child chains this chain composes at RUN TIME. Each entry names a chain (a sibling `chainId` in this pack, or an externally published chain) that a node references via `config.subChainRef`. Unlike RFC 0013's author-time inline splice, a referenced sub-chain is co-instantiated as its own registered workflow and dispatched by the parent as a child run (both parent and child stay owned + builder-editable). A host without runtime child dispatch MUST refuse instantiation with `sub_chain_unsupported` (422) rather than flatten. Requires `capabilities.workflowChainPacks.subChains.supported: true`. See `workflow-chain-packs.md` §\"Sub-chain composition (RFC 0133)\"."
118
+ },
119
+ "producedVariables": {
120
+ "type": "array",
121
+ "items": { "$ref": "#/$defs/ProducedVariable" },
122
+ "description": "RFC 0133 §2.2. OPTIONAL. Run-scoped values a node writes to the executor's variable bag during the run and downstream nodes read by name via a `{ type:\"variable\", variableName }` input binding — where the value is NOT an author-time `parameter` and rides no typed output port. Prefer edges (a typed `sourceOutput`→`targetInput` hand-off) wherever a node exposes a value on a port; `producedVariables` is only for values written to the run bag with no typed port. On expansion the host emits these into `WorkflowDefinition.variables[]` as run-scoped entries (name + type, no value). Any `{ type:\"variable\" }` read of an UNDECLARED name (not here and not a materialized parameter) is a `variable_undeclared` manifest error. See `workflow-chain-packs.md` §\"Produced (run-scoped) variables (RFC 0133)\"."
123
+ }
124
+ },
125
+ "additionalProperties": false
126
+ },
127
+ "SubChainRef": {
128
+ "type": "object",
129
+ "required": ["ref"],
130
+ "description": "RFC 0133 §1.1. A reference to a child chain the parent composes. `ref` is EITHER a string naming a SIBLING `chainId` in this pack's `chains[]`, OR an object naming an externally published chain (resolved + signature-verified like any pack dependency). A sibling `ref` that matches no `chainId`, or an external `ref` that fails resolution/verification, is `sub_chain_unresolved`. A chain that transitively composes itself is `sub_chain_cycle`.",
131
+ "properties": {
132
+ "ref": {
133
+ "oneOf": [
134
+ {
135
+ "type": "string",
136
+ "description": "A sibling `chainId` in the same pack's `chains[]`.",
137
+ "pattern": "^[a-z][a-zA-Z0-9._-]*$",
138
+ "minLength": 1,
139
+ "maxLength": 256
140
+ },
141
+ {
142
+ "type": "object",
143
+ "required": ["packName", "chainId", "version"],
144
+ "description": "An externally published chain, resolved + signature-verified like any pack dependency.",
145
+ "properties": {
146
+ "packName": { "type": "string", "minLength": 1, "maxLength": 256 },
147
+ "chainId": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9._-]*$", "minLength": 1, "maxLength": 256 },
148
+ "version": {
149
+ "type": "string",
150
+ "description": "Semver range for the external chain. Co-registration SHOULD pin the resolved version into the parent's ownership record for reproducibility (RFC 0133 §Unresolved #1, resolved: pin)."
151
+ }
152
+ },
153
+ "additionalProperties": false
154
+ }
155
+ ]
156
+ }
157
+ },
158
+ "additionalProperties": false
159
+ },
160
+ "ProducedVariable": {
161
+ "type": "object",
162
+ "required": ["name", "producedBy", "type"],
163
+ "description": "RFC 0133 §2.2. A run-scoped variable a chain declares: written by one node and read by others via a `{ type:\"variable\", variableName }` input binding. Run-scoped — carries NO author-time value (that is what `parameters` are for); the manifest distinguishes the two.",
164
+ "properties": {
165
+ "name": {
166
+ "type": "string",
167
+ "minLength": 1,
168
+ "description": "The variable-bag key downstream nodes read via `{ type:\"variable\", variableName:\"<name>\" }`."
169
+ },
170
+ "producedBy": {
171
+ "type": "string",
172
+ "minLength": 1,
173
+ "description": "The `nodes[].id` (within this same fragment) that writes the value."
174
+ },
175
+ "type": {
176
+ "type": "string",
177
+ "enum": ["string", "number", "boolean", "object", "array"],
178
+ "description": "JSON-Schema type token for validation/inspection."
179
+ },
180
+ "description": {
181
+ "type": "string",
182
+ "description": "One-line description of the produced value's meaning."
109
183
  }
110
184
  },
111
185
  "additionalProperties": false
@@ -171,9 +245,17 @@
171
245
  },
172
246
  "config": {
173
247
  "type": "object",
174
- "description": "Node config — host-validated against the referenced typeId's config schema. String fields MAY contain `{{params.<name>}}` placeholders that the host MUST substitute at expansion time.",
248
+ "description": "Node config — host-validated against the referenced typeId's config schema. String fields MAY contain `{{params.<name>}}` placeholders that the host MUST substitute at expansion time. RFC 0133: a `core.subWorkflow` / `core.dispatch` (child-run) node MAY carry `subChainRef: string` — a declared `subChains[].ref` (sibling `chainId` or external ref) the host rewrites to the minted child `workflowId` at co-expansion time (§1.3 step 3). A concrete `config.workflowId` inside a fragment remains INVALID — a chain MUST NOT pin a host-specific workflow id (the `not` guard below enforces it).",
175
249
  "additionalProperties": true,
176
- "$comment": "Open by design — node config shapes are per-typeId and only known to the host at expansion time when the referenced typeId's config schema is resolved. Cross-typeId enforcement happens at the expansion step, not at manifest-validation time."
250
+ "not": { "required": ["workflowId"] },
251
+ "properties": {
252
+ "subChainRef": {
253
+ "type": "string",
254
+ "pattern": "^[a-z][a-zA-Z0-9._-]*$",
255
+ "description": "RFC 0133 §1.2. A declared `subChains[].ref` this node dispatches as a child run. The host rewrites it to the minted child `config.workflowId` at co-expansion time. MUST match a `subChains[].ref` (sibling `chainId` or external ref's `chainId`), else `sub_chain_unresolved`."
256
+ }
257
+ },
258
+ "$comment": "Open by design — node config shapes are per-typeId and only known to the host at expansion time when the referenced typeId's config schema is resolved. Cross-typeId enforcement happens at the expansion step, not at manifest-validation time. The `not: { required: ['workflowId'] }` guard is the ONE closed constraint: RFC 0133 forbids pinning a concrete host-specific workflow id inside a portable chain fragment (use `subChainRef` instead)."
177
259
  },
178
260
  "inputs": {
179
261
  "type": "object",
@@ -211,14 +293,15 @@
211
293
  },
212
294
  "EdgeCondition": {
213
295
  "type": "object",
214
- "description": "Edge condition — identical to workflow-definition.schema.json §EdgeCondition (RFC 0013 §edges: 'same shape as a top-level workflow definition'). Inlined here so the manifest schema is self-contained for pack-loader validators.",
296
+ "description": "Edge condition — identical to workflow-definition.schema.json §EdgeCondition (RFC 0013 §edges: 'same shape as a top-level workflow definition'). Inlined here so the manifest schema is self-contained for pack-loader validators. RFC 0134 added `truthy`/`falsy` (kept in sync with the top-level def).",
215
297
  "properties": {
216
298
  "type": {
217
299
  "type": "string",
218
- "enum": ["expression", "equals", "notEquals", "contains", "regex"]
300
+ "enum": ["expression", "equals", "notEquals", "contains", "regex", "truthy", "falsy"],
301
+ "description": "RFC 0134: `truthy`/`falsy` test the resolved value at `left` for truthiness and take NO `right` operand; `left` is required for all operators except `expression`."
219
302
  },
220
- "left": { "type": "string", "description": "Left operand path (e.g., 'status', 'output.approved')." },
221
- "right": { "description": "Right operand value (any JSON value)." },
303
+ "left": { "type": "string", "description": "Left operand path (e.g., 'status', 'output.approved'). Required for every operator except `expression`." },
304
+ "right": { "description": "Right operand value (any JSON value). Meaningless for `truthy`/`falsy` (RFC 0134)." },
222
305
  "expression": { "type": "string", "description": "Used when type='expression'." }
223
306
  },
224
307
  "additionalProperties": false
@@ -197,10 +197,11 @@
197
197
  "properties": {
198
198
  "type": {
199
199
  "type": "string",
200
- "enum": ["expression", "equals", "notEquals", "contains", "regex"]
200
+ "enum": ["expression", "equals", "notEquals", "contains", "regex", "truthy", "falsy"],
201
+ "description": "Operator. `equals`/`notEquals`/`contains` compare `left` to `right`; `regex` matches `left` against `right`; `expression` evaluates `expression`. RFC 0134: `truthy`/`falsy` test the resolved value at `left` for JS-style truthiness and take NO `right` operand (the edge contributes iff `left` is truthy — resp. falsy/absent). `left` is required for all operators except `expression`."
201
202
  },
202
- "left": { "type": "string", "description": "Left operand path (e.g., 'status', 'output.approved')." },
203
- "right": { "description": "Right operand value (any JSON value)." },
203
+ "left": { "type": "string", "description": "Left operand path (e.g., 'status', 'output.approved'). Required for every operator except `expression`." },
204
+ "right": { "description": "Right operand value (any JSON value). Meaningless for `truthy`/`falsy` (RFC 0134) — a host MUST ignore it there, not error." },
204
205
  "expression": { "type": "string", "description": "Used when type='expression'." }
205
206
  },
206
207
  "additionalProperties": false
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Shared helpers for the RFC 0132 anonymous-actor conformance scenarios.
3
+ *
4
+ * Reads the `capabilities.anonymousActor` discovery family at the document
5
+ * ROOT (RFC 0073) and drives the reference-host public-surface seam the
6
+ * behavioral scenarios probe. Lives in lib/ (not a *.test.ts) so the
7
+ * scenarios import it via `../lib/anonymousActor.js`.
8
+ *
9
+ * The seam is a HOST-EXTENSION sample surface (`/v1/host/sample/anon-surface/…`)
10
+ * — NOT a normative protocol endpoint. RFC 0132 pins the wire-observable
11
+ * contract (the capability advert, `owner.principalKind`, the default-deny
12
+ * grant, the audit reuse of `authorization.decided`); the surface→tool
13
+ * binding + the seam route are host-owned config. The behavioral scenarios
14
+ * soft-skip when the seam is unwired (404), so a host that has not yet landed
15
+ * tool-enabled public dispatch stays green.
16
+ *
17
+ * @see spec/v1/capabilities.md §"anonymousActor"
18
+ * @see RFCS/0132-anonymous-actor-authorization.md
19
+ */
20
+ import { driver } from './driver.js';
21
+ import { readCapabilityFamily } from './discovery-capabilities.js';
22
+
23
+ export interface AnonymousActorCap {
24
+ supported?: boolean;
25
+ tiers?: string[];
26
+ writeEgressControls?: string[];
27
+ failClosed?: boolean;
28
+ }
29
+
30
+ /** The reference-host sample public-surface seam (host-extension, not normative). */
31
+ export const ANON_SEAM = '/v1/host/sample/anon-surface';
32
+ /** A sample public surface id the seam resolves to a tenant + explicit grant. */
33
+ export const ANON_SURFACE = 'sample-public-widget';
34
+
35
+ /** Reads `capabilities.anonymousActor` from discovery root; null when unadvertised. */
36
+ export async function readAnonymousActorCap(): Promise<AnonymousActorCap | null> {
37
+ const fam = await readCapabilityFamily<AnonymousActorCap>('anonymousActor');
38
+ return fam && typeof fam === 'object' ? fam : null;
39
+ }
40
+
41
+ /** True when the host advertises the anonymous-actor family. */
42
+ export async function isAnonymousActorAdvertised(): Promise<boolean> {
43
+ const cap = await readAnonymousActorCap();
44
+ return cap?.supported === true;
45
+ }
46
+
47
+ /** The body the reference seam returns for a single anon tool dispatch. */
48
+ export interface AnonDispatchBody {
49
+ authorizationDecided?: {
50
+ event?: string;
51
+ payload?: {
52
+ principal?: string;
53
+ action?: string;
54
+ resource?: string;
55
+ allowed?: boolean;
56
+ reason?: string;
57
+ };
58
+ };
59
+ owner?: { tenant?: string; principal?: string; principalKind?: string };
60
+ egressDecided?: { decision?: string; reason?: string; credentialAttached?: boolean };
61
+ interrupt?: { kind?: string };
62
+ result?: unknown;
63
+ raw?: string;
64
+ }
65
+
66
+ export interface AnonDispatchResult {
67
+ status: number;
68
+ json: AnonDispatchBody | undefined;
69
+ }
70
+
71
+ /**
72
+ * Drive one anonymous-actor tool dispatch against the reference seam.
73
+ * `surface` binds the tenant + explicit grant; `tool` is the tool the anon
74
+ * session attempts. Returns the raw status so callers can soft-skip on 404
75
+ * (seam unwired).
76
+ */
77
+ export async function anonDispatch(body: {
78
+ surface?: string;
79
+ tool: string;
80
+ args?: Record<string, unknown>;
81
+ destination?: string;
82
+ }): Promise<AnonDispatchResult> {
83
+ const res = await driver.post(`${ANON_SEAM}/dispatch`, {
84
+ surface: body.surface ?? ANON_SURFACE,
85
+ tool: body.tool,
86
+ ...(body.args ? { args: body.args } : {}),
87
+ ...(body.destination ? { destination: body.destination } : {}),
88
+ });
89
+ return { status: res.status, json: res.json as AnonDispatchBody | undefined };
90
+ }
91
+
92
+ /** Read the RFC 0078 tool catalog scoped to an anon session on `surface`. */
93
+ export async function anonToolCatalog(
94
+ surface: string = ANON_SURFACE,
95
+ ): Promise<{ status: number; tools: Array<{ name?: string }> }> {
96
+ const res = await driver.get(`${ANON_SEAM}/tools?surface=${encodeURIComponent(surface)}`);
97
+ const body = res.json as { tools?: Array<{ name?: string }> } | undefined;
98
+ return { status: res.status, tools: Array.isArray(body?.tools) ? body!.tools! : [] };
99
+ }