@openwop/openwop-conformance 1.53.1 → 1.57.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 (32) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -2
  3. package/api/openapi.yaml +9 -0
  4. package/coverage.md +3 -0
  5. package/package.json +1 -1
  6. package/schemas/README.md +1 -0
  7. package/schemas/agent-manifest.schema.json +23 -1
  8. package/schemas/capabilities.schema.json +80 -3
  9. package/schemas/connection-pack-manifest.schema.json +5 -0
  10. package/schemas/frontend-plugin-manifest.schema.json +9 -3
  11. package/schemas/residency.schema.json +16 -0
  12. package/schemas/run-snapshot.schema.json +6 -1
  13. package/schemas/workflow-chain-pack-manifest.schema.json +85 -6
  14. package/schemas/workflow-definition.schema.json +4 -3
  15. package/src/lib/anonymousActor.ts +99 -0
  16. package/src/lib/workflow-chain-expansion.ts +342 -0
  17. package/src/scenarios/agent-manifest-role-profile.test.ts +116 -0
  18. package/src/scenarios/anonymous-actor-audit-opaque.test.ts +71 -0
  19. package/src/scenarios/anonymous-actor-default-deny.test.ts +87 -0
  20. package/src/scenarios/anonymous-actor-egress-guarded.test.ts +54 -0
  21. package/src/scenarios/anonymous-actor-no-secret-reach.test.ts +78 -0
  22. package/src/scenarios/anonymous-actor-shape.test.ts +173 -0
  23. package/src/scenarios/anonymous-actor-write-gated.test.ts +83 -0
  24. package/src/scenarios/chain-produced-var-roundtrip.test.ts +152 -0
  25. package/src/scenarios/chain-subchain-cycle-rejected.test.ts +141 -0
  26. package/src/scenarios/chain-subchain-fanout.test.ts +139 -0
  27. package/src/scenarios/chain-subchain-sibling.test.ts +147 -0
  28. package/src/scenarios/chain-subchain-unsupported-refused.test.ts +66 -0
  29. package/src/scenarios/connection-pack-manifest-valid.test.ts +33 -0
  30. package/src/scenarios/data-residency-admission.test.ts +138 -0
  31. package/src/scenarios/edge-condition-truthy-falsy.test.ts +104 -0
  32. package/src/scenarios/frontend-plugin-packs.test.ts +24 -0
@@ -43,6 +43,33 @@ export interface WorkflowChain {
43
43
  dag: { nodes: ReadonlyArray<FragmentNode>; edges?: ReadonlyArray<FragmentEdge> };
44
44
  outputs?: Record<string, { type: string; description: string }>;
45
45
  capabilities?: ReadonlyArray<'streamable' | 'cacheable' | 'side-effectful' | 'mcp-exportable'>;
46
+ /** RFC 0133 §1. Child chains this chain composes at run time. A node
47
+ * references one via `config.subChainRef`; the host co-registers it as
48
+ * its own workflow and dispatches it as a child run. Optional — a chain
49
+ * with none behaves exactly as under RFC 0013. */
50
+ subChains?: ReadonlyArray<SubChainRef>;
51
+ /** RFC 0133 §2. Run-scoped values a node writes to the executor's variable
52
+ * bag and downstream nodes read via a `{ type:"variable" }` input binding.
53
+ * Distinct from author-time `parameters`; carry NO author-time value. */
54
+ producedVariables?: ReadonlyArray<ProducedVariable>;
55
+ }
56
+
57
+ /** RFC 0133 §1.1. A reference to a child chain the parent composes. `ref` is
58
+ * EITHER a sibling `chainId` string (a chain in the SAME pack) OR an object
59
+ * naming an externally published chain (resolved + signature-verified like any
60
+ * pack dependency). */
61
+ export interface SubChainRef {
62
+ ref: string | { packName: string; chainId: string; version: string };
63
+ }
64
+
65
+ /** RFC 0133 §2.2. A run-scoped variable a chain declares: written by one node
66
+ * (`producedBy`) and read by others by `name`. `type` is a JSON-Schema type
67
+ * token for validation/inspection. Run-scoped — no author-time value. */
68
+ export interface ProducedVariable {
69
+ name: string;
70
+ producedBy: string;
71
+ type: string;
72
+ description?: string;
46
73
  }
47
74
 
48
75
  export interface FragmentNode {
@@ -512,3 +539,318 @@ export function expandChainDeferred(
512
539
  configurableSchema,
513
540
  };
514
541
  }
542
+
543
+ // ---------------------------------------------------------------------------
544
+ // RFC 0133 — Workflow-chain composition: sub-chains + produced variables.
545
+ //
546
+ // Two additive extensions to the RFC 0013 model, each usable alone:
547
+ //
548
+ // §1 SUB-CHAINS — a chain declares child chains it composes (`subChains[]`)
549
+ // and references them from a runtime dispatch node via `config.subChainRef`.
550
+ // Unlike RFC 0013's author-time inline splice, a referenced sub-chain is
551
+ // CO-INSTANTIATED as its own registered workflow and dispatched by the
552
+ // parent at run time (the parent genuinely holds the child). Expansion
553
+ // mints a DETERMINISTIC, TENANT-SCOPED child id from (tenantId, childChainId, version) so
554
+ // a repeat instantiation converges and a shared child registers once, then
555
+ // rewrites the referencing node's `config.subChainRef` → `config.workflowId`
556
+ // (the field the runtime already reads). Recursion is bounded by a cycle
557
+ // check + `maxSubChainDepth` (DoS guard — SECURITY `sub-chain-expansion-bounded`).
558
+ //
559
+ // §2 PRODUCED VARIABLES — a chain declares run-scoped values a node writes to
560
+ // the executor's variable bag (`producedVariables[]`), emitted on expansion
561
+ // into `WorkflowDefinition.variables[]`. Any `{ type:"variable" }` input
562
+ // read is validated closed-world against the declared set (+ materialized
563
+ // params); an undeclared read is a `variable_undeclared` manifest error.
564
+ //
565
+ // @see spec/v1/workflow-chain-packs.md §"Sub-chain composition (RFC 0133)"
566
+ // + §"Produced (run-scoped) variables (RFC 0133)"
567
+ // @see RFCS/0133-workflow-chain-composition.md
568
+ // ---------------------------------------------------------------------------
569
+
570
+ /** RECOMMENDED default recursion bound for sub-chain co-expansion (RFC 0133
571
+ * §1.3 — confirms UQ3). A host MAY advertise a different `maxDepth` via
572
+ * `capabilities.workflowChainPacks.subChains.maxDepth`. */
573
+ export const DEFAULT_MAX_SUB_CHAIN_DEPTH = 8;
574
+
575
+ /** Thrown when a `config.subChainRef` (or a `subChains[].ref`) names a sibling
576
+ * chainId that is not present in the same pack, or an external ref that fails
577
+ * resolution/verification. Wire code `sub_chain_unresolved` (HTTP 400) per
578
+ * `workflow-chain-packs.md` §"Error codes" (RFC 0133). */
579
+ export class SubChainUnresolvedError extends Error {
580
+ readonly code = 'sub_chain_unresolved';
581
+ readonly httpStatus = 400;
582
+ constructor(readonly ref: string, readonly chainId: string) {
583
+ super(`sub_chain_unresolved: '${ref}' referenced from chain '${chainId}'`);
584
+ this.name = 'SubChainUnresolvedError';
585
+ }
586
+ }
587
+
588
+ /** Thrown when a chain transitively composes itself (a cycle in the compose
589
+ * graph). Wire code `sub_chain_cycle` (HTTP 400) per `workflow-chain-packs.md`
590
+ * §"Error codes" (RFC 0133). Together with `SubChainDepthExceededError` it is a
591
+ * public test for SECURITY invariant `sub-chain-expansion-bounded`. */
592
+ export class SubChainCycleError extends Error {
593
+ readonly code = 'sub_chain_cycle';
594
+ readonly httpStatus = 400;
595
+ constructor(readonly chainId: string, readonly detail: string) {
596
+ super(`sub_chain_cycle: chain '${chainId}' — ${detail}`);
597
+ this.name = 'SubChainCycleError';
598
+ }
599
+ }
600
+
601
+ /** Thrown when co-expansion nesting exceeds `maxSubChainDepth` — the DoS depth
602
+ * backstop, DISTINCT from an actual cycle so an operator sees WHICH bound fired.
603
+ * Wire code `sub_chain_max_depth_exceeded` (HTTP 400) per `workflow-chain-packs.md`
604
+ * §"Error codes" (RFC 0133). A public test for `sub-chain-expansion-bounded`. */
605
+ export class SubChainDepthExceededError extends Error {
606
+ readonly code = 'sub_chain_max_depth_exceeded';
607
+ readonly httpStatus = 400;
608
+ constructor(readonly chainId: string, readonly maxDepth: number) {
609
+ super(`sub_chain_max_depth_exceeded: chain '${chainId}' exceeds maxSubChainDepth ${maxDepth}`);
610
+ this.name = 'SubChainDepthExceededError';
611
+ }
612
+ }
613
+
614
+ /** Thrown when a node input binding `{ type:"variable", variableName }` reads a
615
+ * name that is neither a declared `producedVariables[].name` nor a materialized
616
+ * parameter — the closed-world guard that closes the "reads a value nothing
617
+ * produces" hole. Wire code `variable_undeclared` (HTTP 400) per
618
+ * `workflow-chain-packs.md` §"Error codes" (RFC 0133). */
619
+ export class VariableUndeclaredError extends Error {
620
+ readonly code = 'variable_undeclared';
621
+ readonly httpStatus = 400;
622
+ constructor(readonly variableName: string, readonly chainId: string) {
623
+ super(`variable_undeclared: '${variableName}' read in chain '${chainId}'`);
624
+ this.name = 'VariableUndeclaredError';
625
+ }
626
+ }
627
+
628
+ /** Thrown when a `producedVariables[].producedBy` names a node id that does not
629
+ * exist in the same fragment — a malformed producer declaration (the value would
630
+ * be produced by nothing). Distinct from `variable_undeclared` (a bad READER) so
631
+ * an operator sees which side is malformed. Wire code `produced_var_producer_unknown`
632
+ * (HTTP 400) per `workflow-chain-packs.md` §"Error codes" (RFC 0133). */
633
+ export class ProducedVarProducerUnknownError extends Error {
634
+ readonly code = 'produced_var_producer_unknown';
635
+ readonly httpStatus = 400;
636
+ constructor(readonly variableName: string, readonly producedBy: string, readonly chainId: string) {
637
+ super(`produced_var_producer_unknown: '${variableName}' producedBy unknown node '${producedBy}' in chain '${chainId}'`);
638
+ this.name = 'ProducedVarProducerUnknownError';
639
+ }
640
+ }
641
+
642
+ /** The canonical `subChains[].ref` chainId — a string ref is the sibling
643
+ * chainId; an object ref's `chainId` is the external chain's id. */
644
+ function refChainId(ref: SubChainRef['ref']): string {
645
+ return typeof ref === 'string' ? ref : ref.chainId;
646
+ }
647
+
648
+ /** Deterministic child workflow id minted from **(tenantId, childChainId, version)**
649
+ * per RFC 0133 §1.3 step 2. TENANT-SCOPED by construction: the `registerWorkflow`
650
+ * registry is global by-id, so a tenant-less id would let two tenants instantiating
651
+ * the same parent→child COLLIDE on one global workflow (a cross-tenant isolation
652
+ * break — SECURITY `sub-chain-child-tenant-scoped`). Keying on `tenantId` also makes
653
+ * the dedup correct ACROSS PARENTS within a tenant: a child chain composed by two
654
+ * different parents in the same tenant registers exactly once (a repeat instantiation
655
+ * converges). `version` distinguishes `lesson-batch@1` from `lesson-batch@2`. Dots +
656
+ * unsafe chars are slugged (storage-key safety). */
657
+ export function mintChildWorkflowId(tenantId: string, childChainId: string, version: string): string {
658
+ const slug = (s: string): string => s.replace(/[^0-9A-Za-z._-]/g, '_').replace(/\./g, '_');
659
+ return `wfc_${slug(tenantId)}__${slug(childChainId)}__${slug(version)}`;
660
+ }
661
+
662
+ /** A child chain co-registered as its own workflow during parent expansion. */
663
+ export interface CoRegisteredChild {
664
+ /** Deterministic minted id (see `mintChildWorkflowId`). */
665
+ childWorkflowId: string;
666
+ /** The composed chain's `chainId`. */
667
+ chainId: string;
668
+ /** The child's own expanded fragment (registered as a standalone workflow). */
669
+ fragment: ExpandedFragment;
670
+ }
671
+
672
+ export interface ChainTreeExpansion {
673
+ /** The parent fragment — every `config.subChainRef` rewritten to the minted
674
+ * child `config.workflowId`. */
675
+ parent: ExpandedFragment;
676
+ /** Every co-registered child, deduplicated by `childWorkflowId` (a child
677
+ * referenced twice registers once). */
678
+ children: ReadonlyArray<CoRegisteredChild>;
679
+ }
680
+
681
+ export interface CoExpansionContext {
682
+ /** Unique tag for this parent instantiation — seeds the parent node-id prefix
683
+ * (per-drop node-id uniqueness). Does NOT seed the child workflow id (that is
684
+ * tenant-scoped — see `tenantId`). */
685
+ parentExpansionId: string;
686
+ /** The instantiating tenant. The deterministic CHILD workflow id is scoped to
687
+ * this tenant so two tenants never collide on the global by-id registry
688
+ * (SECURITY `sub-chain-child-tenant-scoped`) and a child shared across parents
689
+ * within the tenant dedups to one registration. */
690
+ tenantId: string;
691
+ params: Record<string, unknown>;
692
+ isTypeIdResolvable: (typeId: string) => boolean;
693
+ /** Sibling chains available in the SAME pack, keyed by `chainId`. External
694
+ * refs (object form) are resolved host-side and supplied here too when the
695
+ * host wants them co-expanded in-process; a ref absent from this map is
696
+ * `sub_chain_unresolved`. */
697
+ siblingChains: ReadonlyMap<string, WorkflowChain>;
698
+ /** Recursion bound (default `DEFAULT_MAX_SUB_CHAIN_DEPTH`). */
699
+ maxDepth?: number;
700
+ }
701
+
702
+ /** Collect every distinct `config.subChainRef` reachable from a chain's nodes. */
703
+ function subChainRefsInNodes(chain: WorkflowChain): string[] {
704
+ const refs: string[] = [];
705
+ for (const n of chain.dag.nodes) {
706
+ const r = n.config?.['subChainRef'];
707
+ if (typeof r === 'string' && !refs.includes(r)) refs.push(r);
708
+ }
709
+ return refs;
710
+ }
711
+
712
+ /**
713
+ * Co-expand a parent chain and every sub-chain it composes (RFC 0133 §1.3).
714
+ * Resolves each `config.subChainRef` to a sibling chain, recursively co-expands
715
+ * it, mints a deterministic child workflow id, and rewrites the referencing
716
+ * node's `config.subChainRef` → `config.workflowId`. Bounded by a cycle check
717
+ * and `maxDepth`.
718
+ *
719
+ * @throws SubChainUnresolvedError when a ref names no sibling chain.
720
+ * @throws SubChainCycleError on a compose cycle or a depth-bound breach.
721
+ * @throws ChainUnresolvableTypeIdError when any node typeId fails resolution.
722
+ */
723
+ export function expandChainTree(
724
+ root: WorkflowChain,
725
+ ctx: CoExpansionContext,
726
+ ): ChainTreeExpansion {
727
+ const maxDepth = ctx.maxDepth ?? DEFAULT_MAX_SUB_CHAIN_DEPTH;
728
+ const children = new Map<string, CoRegisteredChild>();
729
+
730
+ // DFS over the compose graph with an on-stack set for cycle detection.
731
+ const onStack = new Set<string>();
732
+
733
+ const expandOne = (chain: WorkflowChain, depth: number): ExpandedFragment => {
734
+ if (depth > maxDepth) {
735
+ throw new SubChainDepthExceededError(chain.chainId, maxDepth);
736
+ }
737
+ if (onStack.has(chain.chainId)) {
738
+ throw new SubChainCycleError(chain.chainId, 'chain transitively composes itself');
739
+ }
740
+ onStack.add(chain.chainId);
741
+
742
+ // Validate declared subChains[] resolve, and recurse into each referenced
743
+ // sibling BEFORE rewriting the parent so child ids exist to splice in.
744
+ const declared = new Set((chain.subChains ?? []).map((s) => refChainId(s.ref)));
745
+ for (const ref of subChainRefsInNodes(chain)) {
746
+ // A node ref MUST be a declared subChains[] entry (RFC 0133 §1.2).
747
+ if (!declared.has(ref)) throw new SubChainUnresolvedError(ref, chain.chainId);
748
+ const sibling = ctx.siblingChains.get(ref);
749
+ if (!sibling) throw new SubChainUnresolvedError(ref, chain.chainId);
750
+ // Tenant-scoped, version-pinned deterministic child id (§1.3 step 2).
751
+ const childId = mintChildWorkflowId(ctx.tenantId, ref, sibling.version);
752
+ if (!children.has(childId)) {
753
+ // Recurse first; the child fragment is registered under the minted id.
754
+ const childFragment = expandOne(sibling, depth + 1);
755
+ children.set(childId, { childWorkflowId: childId, chainId: ref, fragment: childFragment });
756
+ }
757
+ }
758
+
759
+ // Expand this chain's own fragment (reuses the RFC 0013 algorithm), then
760
+ // rewrite each subChainRef node: `config.subChainRef` → `config.workflowId`
761
+ // (the minted child id the runtime dispatches).
762
+ const expansionId = chain.chainId === root.chainId
763
+ ? ctx.parentExpansionId
764
+ : `${ctx.parentExpansionId}_${chain.chainId.replace(/\./g, '_')}`;
765
+ const fragment = expandChain(chain, {
766
+ expansionId,
767
+ params: ctx.params,
768
+ isTypeIdResolvable: ctx.isTypeIdResolvable,
769
+ });
770
+ const rewrittenNodes = fragment.nodes.map((n) => {
771
+ const ref = (n.config as Record<string, unknown> | undefined)?.['subChainRef'];
772
+ if (typeof ref !== 'string') return n;
773
+ const refVersion = ctx.siblingChains.get(ref)?.version ?? '0.0.0';
774
+ const { subChainRef: _drop, ...restConfig } = n.config as Record<string, unknown>;
775
+ return { ...n, config: { ...restConfig, workflowId: mintChildWorkflowId(ctx.tenantId, ref, refVersion) } };
776
+ });
777
+
778
+ onStack.delete(chain.chainId);
779
+ return { nodes: rewrittenNodes, edges: fragment.edges, idMap: fragment.idMap };
780
+ };
781
+
782
+ const parent = expandOne(root, 0);
783
+ return { parent, children: [...children.values()] };
784
+ }
785
+
786
+ /** Emit a chain's declared `producedVariables[]` as run-scoped `variables[]`
787
+ * entries (name + type, NO value) for the expanded `WorkflowDefinition`
788
+ * (RFC 0133 §2.3). The executor's existing variable bag carries them exactly
789
+ * as in a hand-authored workflow — no new runtime surface. */
790
+ export function emitProducedVariables(
791
+ chain: WorkflowChain,
792
+ ): ReadonlyArray<{ name: string; type: string }> {
793
+ return (chain.producedVariables ?? []).map((p) => ({ name: p.name, type: p.type }));
794
+ }
795
+
796
+ /** Walk a value for `{ type:"variable", variableName }` input bindings, collecting
797
+ * every referenced variable name. */
798
+ function collectVariableReads(value: unknown, out: Set<string>): void {
799
+ if (Array.isArray(value)) {
800
+ for (const v of value) collectVariableReads(v, out);
801
+ return;
802
+ }
803
+ if (value !== null && typeof value === 'object') {
804
+ const obj = value as Record<string, unknown>;
805
+ if (obj['type'] === 'variable' && typeof obj['variableName'] === 'string') {
806
+ out.add(obj['variableName']);
807
+ }
808
+ for (const v of Object.values(obj)) collectVariableReads(v, out);
809
+ }
810
+ }
811
+
812
+ /**
813
+ * Closed-world validation of a chain's run-variable reads (RFC 0133 §2.2). Every
814
+ * `{ type:"variable", variableName }` binding in any node's `inputs` MUST reference
815
+ * a declared `producedVariables[].name` OR a materialized parameter name. An
816
+ * undeclared read throws `VariableUndeclaredError` — closing the "reads a value
817
+ * nothing produces" hole.
818
+ *
819
+ * @param materializedParams parameter names the host materialized as variables
820
+ * (deferred mode, RFC 0124); empty for expansion-time substitution.
821
+ * @throws VariableUndeclaredError on the first undeclared variable read.
822
+ */
823
+ export function validateVariableReads(
824
+ chain: WorkflowChain,
825
+ materializedParams: ReadonlySet<string> = new Set(),
826
+ ): void {
827
+ // Producer-existence (RFC 0133 §2.2): every producedVariables[].producedBy MUST
828
+ // name a real node id in the fragment — a producedBy naming a non-existent node
829
+ // is a malformed producer (the value would be written by nothing).
830
+ const nodeIds = new Set(chain.dag.nodes.map((n) => n.id));
831
+ for (const p of chain.producedVariables ?? []) {
832
+ if (!nodeIds.has(p.producedBy)) {
833
+ throw new ProducedVarProducerUnknownError(p.name, p.producedBy, chain.chainId);
834
+ }
835
+ }
836
+
837
+ // Disjointness (RFC 0133 §2.2): a producedVariables name MUST NOT collide with a
838
+ // `parameters` property name — the author-time and run-scoped channels are disjoint.
839
+ const paramNames = new Set<string>(materializedParams);
840
+ const paramProps = (chain.parameters as { properties?: Record<string, unknown> } | undefined)?.properties;
841
+ if (paramProps) for (const k of Object.keys(paramProps)) paramNames.add(k);
842
+ for (const p of chain.producedVariables ?? []) {
843
+ if (paramNames.has(p.name)) throw new VariableUndeclaredError(p.name, chain.chainId);
844
+ }
845
+
846
+ const declared = new Set<string>(materializedParams);
847
+ for (const p of chain.producedVariables ?? []) declared.add(p.name);
848
+ const reads = new Set<string>();
849
+ for (const n of chain.dag.nodes) {
850
+ if (n.inputs !== undefined) collectVariableReads(n.inputs, reads);
851
+ if (n.config !== undefined) collectVariableReads(n.config, reads);
852
+ }
853
+ for (const name of reads) {
854
+ if (!declared.has(name)) throw new VariableUndeclaredError(name, chain.chainId);
855
+ }
856
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Agent-manifest `role` + the Skill profile (RFC 0131).
3
+ *
4
+ * Always-on, server-free schema-shape probe of the additive optional
5
+ * `AgentManifest.role` and the schema-encoded Skill profile (§B). The profile is
6
+ * a JSON-Schema `if role==="skill" then {required:["handoff"], memoryShape.
7
+ * {conversation,longTerm} !== true}` conditional, so a violating skill manifest
8
+ * FAILS validation at publish/install — a malformed manifest (RFC 0003 §C author
9
+ * error), NOT an RFC 0072 §C `degraded[]` runtime tier. This is the public
10
+ * witness for the SECURITY invariant `agent-skill-profile-stateless`.
11
+ *
12
+ * Verifies:
13
+ * - `role` is EXPLICIT, never inferred: a manifest with NO `role` and any
14
+ * `memoryShape` (incl. conversation+longTerm) validates — unconstrained,
15
+ * exactly today's meaning. `handoff` presence does NOT reclassify it.
16
+ * - a `role:"skill"` manifest WITH `handoff` + scratchpad-only memory validates.
17
+ * - a `role:"skill"` manifest with `memoryShape.longTerm:true` FAILS validation.
18
+ * - a `role:"skill"` manifest with `memoryShape.conversation:true` FAILS.
19
+ * - a `role:"skill"` manifest MISSING `handoff` FAILS validation.
20
+ * - a `role:"assistant"` manifest with conversation+longTerm (+ optional
21
+ * handoff) validates — no profile binds it.
22
+ * - `role` outside the `["skill","assistant"]` enum FAILS.
23
+ *
24
+ * Spec references:
25
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0131-agent-manifest-role-and-skill-profile.md
26
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/agent-memory.md (§B — reject vs §C degrade)
27
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0072-agent-inventory-and-dispatch.md (the degraded[] marker this RFC does NOT overload)
28
+ * - https://github.com/openwop/openwop/blob/main/SECURITY/invariants.yaml (agent-skill-profile-stateless)
29
+ */
30
+
31
+ import { describe, it, expect } from 'vitest';
32
+ import { readFileSync, readdirSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import Ajv2020 from 'ajv/dist/2020.js';
35
+ import addFormats from 'ajv-formats';
36
+ import { SCHEMAS_DIR } from '../lib/paths.js';
37
+
38
+ const BASE = 'https://openwop.dev/spec/v1/';
39
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
40
+ function loadSchema(name: string): Record<string, unknown> {
41
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
42
+ }
43
+
44
+ describe('agent-manifest-role-profile: AgentManifest.role + Skill profile (RFC 0131, server-free)', () => {
45
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
46
+ addFormats(ajv);
47
+ for (const f of readdirSync(SCHEMAS_DIR)) {
48
+ if (f.endsWith('.schema.json')) {
49
+ try {
50
+ ajv.addSchema(loadSchema(f));
51
+ } catch {
52
+ /* duplicate/ignore */
53
+ }
54
+ }
55
+ }
56
+ const manifest = ajv.getSchema(`${BASE}agent-manifest.schema.json`)!;
57
+
58
+ const base = { agentId: 'core.openwop.agents.demo', persona: 'Demo', modelClass: 'general', systemPrompt: 'do it' };
59
+ const handoff = { taskSchemaRef: 'schemas/task.json', returnSchemaRef: 'schemas/return.json' };
60
+
61
+ it('NO role + any memoryShape (conversation+longTerm) validates — explicit, never inferred', () => {
62
+ expect(
63
+ manifest({ ...base, memoryShape: { scratchpad: true, conversation: true, longTerm: true } }),
64
+ why('RFC 0131 §A', 'absent role ⇒ unconstrained; nothing reclassifies (today’s meaning, unchanged)'),
65
+ ).toBe(true);
66
+ });
67
+
68
+ it('NO role + handoff + rich memory validates — handoff presence does NOT imply skill', () => {
69
+ expect(
70
+ manifest({ ...base, handoff, memoryShape: { conversation: true, longTerm: true } }),
71
+ why('RFC 0131 §A / Motivation 2', 'handoff is an interop contract, orthogonal to role — no inference'),
72
+ ).toBe(true);
73
+ });
74
+
75
+ it('role:"skill" WITH handoff + scratchpad-only memory validates', () => {
76
+ expect(
77
+ manifest({ ...base, role: 'skill', handoff, memoryShape: { scratchpad: true, conversation: false, longTerm: false } }),
78
+ why('RFC 0131 §B', 'a well-formed skill (handoff + scratchpad-only) MUST validate'),
79
+ ).toBe(true);
80
+ });
81
+
82
+ it('role:"skill" with memoryShape.longTerm:true FAILS validation (malformed, not degraded)', () => {
83
+ expect(
84
+ manifest({ ...base, role: 'skill', handoff, memoryShape: { scratchpad: true, longTerm: true } }),
85
+ why('RFC 0131 §B', 'a skill declaring longTerm memory is malformed — reject at publish/install'),
86
+ ).toBe(false);
87
+ });
88
+
89
+ it('role:"skill" with memoryShape.conversation:true FAILS validation', () => {
90
+ expect(
91
+ manifest({ ...base, role: 'skill', handoff, memoryShape: { conversation: true } }),
92
+ why('RFC 0131 §B', 'a skill declaring conversation memory is malformed — reject'),
93
+ ).toBe(false);
94
+ });
95
+
96
+ it('role:"skill" MISSING handoff FAILS validation', () => {
97
+ expect(
98
+ manifest({ ...base, role: 'skill', memoryShape: { scratchpad: true } }),
99
+ why('RFC 0131 §B', 'a skill MUST declare handoff (task→return capability)'),
100
+ ).toBe(false);
101
+ });
102
+
103
+ it('role:"assistant" with conversation+longTerm (and handoff) validates — no profile binds it', () => {
104
+ expect(
105
+ manifest({ ...base, role: 'assistant', handoff, memoryShape: { scratchpad: true, conversation: true, longTerm: true } }),
106
+ why('RFC 0131 §A', 'an assistant’s conversation + long-term memory are legitimate; it MAY ship handoff'),
107
+ ).toBe(true);
108
+ });
109
+
110
+ it('role outside the ["skill","assistant"] enum FAILS', () => {
111
+ expect(
112
+ manifest({ ...base, role: 'agent' }),
113
+ why('RFC 0131 §A', '"agent" is not an enum value (it is the overloaded word the RFC removes)'),
114
+ ).toBe(false);
115
+ });
116
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Anonymous-actor audit is opaque + non-PII (RFC 0132 §D) — backs the
3
+ * `anon-actor-audit-opaque` SECURITY invariant (RFC 0048 identifier-opacity + SR-1).
4
+ *
5
+ * Every anonymous-actor tool call emits an `authorization.decided` record (RFC
6
+ * 0049 — no new event minted) attributable to the opaque anon-session
7
+ * `principal`. The `principal` MUST be opaque, non-cross-linkable, and non-PII
8
+ * (no IP, email, device fingerprint), and the record MUST carry no credential
9
+ * material. The run snapshot echoes `owner.principalKind: "anonymous"`.
10
+ *
11
+ * Capability-gated on `capabilities.anonymousActor.supported`; soft-skips when
12
+ * unadvertised or when the seam is unwired (404). Hard-fails under
13
+ * `OPENWOP_REQUIRE_BEHAVIOR=true`. Passing non-vacuously graduates
14
+ * `anon-actor-audit-opaque` reference-impl → protocol tier.
15
+ *
16
+ * @see RFCS/0132-anonymous-actor-authorization.md §A, §D
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { behaviorGate } from '../lib/behavior-gate.js';
21
+ import { driver } from '../lib/driver.js';
22
+ import { isAnonymousActorAdvertised, anonDispatch } from '../lib/anonymousActor.js';
23
+
24
+ const PROFILE = 'openwop-anonymous-actor';
25
+
26
+ /** Reject anything that looks like PII the anon id MUST NOT embed. */
27
+ const PII_PATTERNS: Array<[RegExp, string]> = [
28
+ [/\b\d{1,3}(?:\.\d{1,3}){3}\b/, 'an IPv4 address'],
29
+ [/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/, 'an email address'],
30
+ ];
31
+
32
+ describe('anonymous-actor-audit-opaque (RFC 0132 §D)', () => {
33
+ it('the authorization.decided principal is opaque, non-PII, and the record carries no credential', async () => {
34
+ if (!behaviorGate(PROFILE, await isAnonymousActorAdvertised())) return;
35
+ const res = await anonDispatch({ tool: 'catalog.read' });
36
+ if (res.status === 404 || res.status === 405) return; // seam unwired — soft-skip
37
+
38
+ const decided = res.json?.authorizationDecided?.payload;
39
+ expect(
40
+ decided?.principal,
41
+ driver.describe('RFC 0132 §D', 'an anon tool call MUST emit authorization.decided with a principal'),
42
+ ).toBeTruthy();
43
+
44
+ const principal = decided?.principal ?? '';
45
+ for (const [pattern, label] of PII_PATTERNS) {
46
+ expect(
47
+ pattern.test(principal),
48
+ driver.describe('SECURITY anon-actor-audit-opaque', `the anon principal MUST NOT embed ${label}`),
49
+ ).toBe(false);
50
+ }
51
+ // The record MUST NOT carry credential material (reason is redaction-safe).
52
+ const serialized = JSON.stringify(decided ?? {});
53
+ for (const [pattern, label] of PII_PATTERNS) {
54
+ expect(
55
+ pattern.test(serialized),
56
+ driver.describe('SECURITY anon-actor-audit-opaque', `the audit record MUST NOT carry ${label}`),
57
+ ).toBe(false);
58
+ }
59
+ });
60
+
61
+ it('the run snapshot echoes owner.principalKind "anonymous"', async () => {
62
+ if (!behaviorGate(PROFILE, await isAnonymousActorAdvertised())) return;
63
+ const res = await anonDispatch({ tool: 'catalog.read' });
64
+ if (res.status === 404 || res.status === 405) return; // seam unwired — soft-skip
65
+ if (!res.json?.owner) return; // seam does not echo an owner triple — nothing to assert
66
+ expect(
67
+ res.json.owner.principalKind,
68
+ driver.describe('RFC 0132 §A', 'an anon-authorized run MUST set owner.principalKind "anonymous"'),
69
+ ).toBe('anonymous');
70
+ });
71
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Anonymous-actor default-deny grant (RFC 0132 §C.1) — backs the
3
+ * `anon-actor-no-default-baseline` SECURITY invariant.
4
+ *
5
+ * An anonymous actor is granted ONLY the tools explicitly listed in the
6
+ * resolved surface allowlist — never a default-on tool baseline, never a tool
7
+ * granted to authenticated agents by default. The effective granted set is
8
+ * discoverable through the RFC 0078 tool-catalog read scoped to the anon
9
+ * principal, which fails EMPTY when the surface grants nothing (never a
10
+ * baseline). A call to a non-granted tool MUST resolve to
11
+ * `authorization.decided { allowed:false, reason:"anon-not-granted" }` with NO
12
+ * dispatch.
13
+ *
14
+ * Capability-gated on `capabilities.anonymousActor.supported` +
15
+ * `behaviorGate('openwop-anonymous-actor', …)`; soft-skips when unadvertised or
16
+ * when the reference public-surface seam is unwired (404). Hard-fails under
17
+ * `OPENWOP_REQUIRE_BEHAVIOR=true`. When it passes non-vacuously the
18
+ * `anon-actor-no-default-baseline` invariant graduates reference-impl → protocol
19
+ * tier (RFC 0079 precedent).
20
+ *
21
+ * @see RFCS/0132-anonymous-actor-authorization.md §C.1
22
+ * @see conformance/coverage.md §"Capability-gated scenarios"
23
+ */
24
+
25
+ import { describe, it, expect } from 'vitest';
26
+ import { behaviorGate } from '../lib/behavior-gate.js';
27
+ import { driver } from '../lib/driver.js';
28
+ import {
29
+ isAnonymousActorAdvertised,
30
+ anonDispatch,
31
+ anonToolCatalog,
32
+ readAnonymousActorCap,
33
+ } from '../lib/anonymousActor.js';
34
+
35
+ const PROFILE = 'openwop-anonymous-actor';
36
+
37
+ /** A tool no public surface would ever grant — an authenticated default-on baseline action. */
38
+ const UNGRANTED_TOOL = 'crm.contact.delete';
39
+
40
+ describe('anonymous-actor-default-deny (RFC 0132 §C.1)', () => {
41
+ it('the anon tool catalog returns only the explicit surface grant — never a default baseline', async () => {
42
+ if (!behaviorGate(PROFILE, await isAnonymousActorAdvertised())) return;
43
+ const cat = await anonToolCatalog();
44
+ if (cat.status === 404 || cat.status === 405) return; // seam unwired — soft-skip
45
+ expect(
46
+ cat.status,
47
+ driver.describe('RFC 0132 §C.1', 'the anon tool-catalog read MUST resolve (scoped to the anon principal)'),
48
+ ).toBe(200);
49
+ // Default-deny: the ungranted baseline tool MUST NOT appear in the anon catalog.
50
+ const names = cat.tools.map((t) => t.name);
51
+ expect(
52
+ names,
53
+ driver.describe('RFC 0132 §C.1', 'a default-on baseline tool MUST NOT appear in the anon grant'),
54
+ ).not.toContain(UNGRANTED_TOOL);
55
+ });
56
+
57
+ it('calling a non-granted tool denies with reason "anon-not-granted" and does not dispatch', async () => {
58
+ if (!behaviorGate(PROFILE, await isAnonymousActorAdvertised())) return;
59
+ const res = await anonDispatch({ tool: UNGRANTED_TOOL });
60
+ if (res.status === 404 || res.status === 405) return; // seam unwired — soft-skip
61
+ const decided = res.json?.authorizationDecided?.payload;
62
+ expect(
63
+ decided?.allowed,
64
+ driver.describe('RFC 0132 §C.1', 'a non-granted anon tool MUST be denied (allowed:false), never default-allowed'),
65
+ ).toBe(false);
66
+ expect(
67
+ decided?.reason,
68
+ driver.describe('RFC 0132 §C.1', 'a default-deny denial carries the machine reason "anon-not-granted"'),
69
+ ).toBe('anon-not-granted');
70
+ // No dispatch — the seam MUST NOT return a tool result for a denied call.
71
+ expect(
72
+ res.json?.result,
73
+ driver.describe('RFC 0132 §C.1', 'a denied anon tool MUST NOT dispatch (no result)'),
74
+ ).toBeUndefined();
75
+ });
76
+
77
+ it('a host that advertises bounded-write-egress also advertises a mandatory control (truthful-advertisement)', async () => {
78
+ const cap = await readAnonymousActorCap();
79
+ if (!behaviorGate(PROFILE, cap?.supported === true)) return;
80
+ if ((cap?.tiers ?? []).includes('bounded-write-egress')) {
81
+ expect(
82
+ (cap?.writeEgressControls ?? []).length,
83
+ driver.describe('RFC 0132 §B.2', 'bounded-write-egress MUST advertise ≥1 writeEgressControl'),
84
+ ).toBeGreaterThan(0);
85
+ }
86
+ });
87
+ });