diff --git a/UPSTREAM.json b/UPSTREAM.json index 9ba37bf..44a4735 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -17,6 +17,7 @@ "broker/generality_test.go": "14b83f8dd8e25562cd5d099e7d0b9285d15d2874cddd42f3901303c0553ee596", "clients/.gitignore": "e44a4a6d3c3287bc82212abbac01e1992247c3cb2b3e5bff8ecfd7ee73788bb5", "clients/gen/main.go": "2a11e7de9fe7eb1ed8a19435ee5421923a399bc32834da94c0497b5d59728016", + "clients/python/examples/decision-request.py": "0debe9f27023bbcf1b0487a2b343825f061a1920b0f2d1e7d82b7ece2d6f6583", "clients/python/examples/parity.py": "8d530dd043ba55b4369f4b3d9945c98bcd9276f84dace7517df4fb3f326cb431", "clients/python/pyproject.toml": "b93eaaa71294f43cc50608a4a84d1dc8277fb4b69115476a188c55c89933a7b5", "clients/python/src/interlock_protocol/__init__.py": "6aaeb0f5b560354c36b4e63993d1826d4b70f8120d02044629132156a45156d4", @@ -24,6 +25,7 @@ "clients/python/src/interlock_protocol/protocol.py": "8dd6225bf068269e14290841c2b0c5b88e101978814009e83f25a57f797bf4fb", "clients/python/src/interlock_protocol/py.typed": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "clients/schema/interlock.schema.json": "b637af7ab11b324f9b018656942552858143b412e5083d18285abadc6d056eba", + "clients/typescript/examples/decision-request.ts": "0a398c305f1fc89b7d0c70c68e34f56c170ba0ee456e0447103c0419c50ad159", "clients/typescript/examples/parity.ts": "2d73ad5d3c9f2b87bf3755fd029f449afcfcc3dd820877f02ae516228196a1b1", "clients/typescript/package.json": "d6b747a44a20a2377274e7f5cc12bf292fdec10f60fb7c099a17e3b3bd6f0837", "clients/typescript/src/canonical.ts": "be376928b4563f998a5879201a9cc36407b9c13218ed6357722424e044d1247b", @@ -68,19 +70,20 @@ "examples/exclusive-publish/policy.go": "d18df2ada6684bc1815d19b4918b7ba8b7b584d7fd75e9fc7f8c0562299cd94c", "examples/generated-file-protection/policy.go": "0a9a5b33990adb770c74382bbab7e618582fd8c2ce6632deba5ca9cc652696ee", "examples/release-manifest/policy.go": "d2f005addc2842f0ec235feb64315776aa936aa2e49bb82325867b3c9bcb691c", - "examples/release-manifest/publish_test.go": "25bad210cd30e1d0e2ef4f6328856d18919a5fcda3bfd6d72ce2d770ffeb4868", + "examples/release-manifest/publish_test.go": "7594fc7ff5a2abaf3216c74879c8d5873b636f8091d532c39d93971a276f4f4c", "examples/repository-policy/policy.go": "8c7fe12c487c1369d5ce8ae24e647e050557cd2bf29c56d4010b782603debbd1", "go.mod": "a9a846b064eac2e330c18198dae19044e438bff31dcb26d928cdb2f9b5cae3e1", "install.ps1": "ada3f94562569929446dd6ca200f993854fa10081785e3ad1a09db285dacca6a", "install.sh": "7d62cccc9b35280490c3332279743050957ab7e648b4b115c86211ba8995ab4e", "interlock.go": "de4ff7ee8dd15ab12d0bea387b66ad6e7abfbb66553839ecc0befc32ec8fb606", "interlock_test.go": "06b56780f7ae90fe12b055bf426aab8b87ed4fe174698a49d729bdc6484299e2", - "ir/ir.go": "20fe77a927c8f89ac90b5a806579854934ff5d3977a014c236d30377860ec310", + "ir/ir.go": "8765290a06278981beff40410092c63e0df3b1561e4cbd7e6c63217f1213390d", "ir/ir_test.go": "64a962436ee5cab261093f8cb455ad92075683a7ef58997dac1e022e09d1a46a", "proof/proof.go": "22a3baeee05bfb1e9f2be549a07b873ddc51d36037711ffef31e77ee161c24fb", "proof/proof_test.go": "8151c10b3efc95e8eb66841ce979093a35263adaf0b6f337b1fe5a3184769123", "protocol/protocol.go": "1d9cc6ba1d59635e9c88ab9a20058491c78d0024e5e87fd25ec87628271c728e", - "publish/publish.go": "2434a5c00da06f97a27aae77f6b9ca8a006027e37123eac627032cce0a7f3434", + "publish/publish.go": "8ce82d43f8912dac520bc75c3be7506bd2871e747caa6e752102e4ff27c0e4d7", + "publish/publish_test.go": "2794f054e3fb8aba3d4ea126c15be97d2cfdb59aabe82ecef252957a2f5e0faf", "receipt/receipt.go": "8a21b9054b599965221e08fe158887759473d2f41d28dd3df42a1ffe1c55cb6e", "receipt/receipt_test.go": "a215a205205573bb0fbf6d121aa761bc28510346b92e8bb72dd85ca90b6fa65c", "scaffold/demo.go": "d496e57f31a17165a540f704704ae341e66641139795263bee2dcfb9ca1de7c7", @@ -95,7 +98,7 @@ "generator": "operatorstack/interlock:project-upstream", "schema_version": 1, "source": { - "commit": "9ec0e3c0251ee23dd771b8a42d0f9f7b34d4d4d7", + "commit": "74a9630b632faae5e421c4046ff5a6d516c0f7dd", "path": "labs/21-interlock", "repository": "operatorstack/intelligence-flow" } diff --git a/clients/python/examples/decision-request.py b/clients/python/examples/decision-request.py new file mode 100644 index 0000000..6738053 --- /dev/null +++ b/clients/python/examples/decision-request.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# decision-request.py shows the winning cross-language ergonomic in Python: a +# caller names the resource by its POLICY ID and gets a typed EffectRequest with +# the URI+kind resolved from the policy — never restating a resource's URI/kind and +# never hand-writing the request JSON. The request is emitted with the same +# canonical encoder the hash is computed from. +# +# Authority boundary — read this before extending the example. This client does +# NOT decide and does NOT enforce. It builds a typed, canonically-encoded request. +# The decision is made by the Go engine (behind the interlock-pitot controller), +# and the protected effect is performed by the Go broker. Porting the decision +# engine or the broker into Python is an explicit non-goal: the canonical IR is the +# execution language, Go is the trusted executable. So this file resolves a +# resource and shapes a request; it makes no allow/deny claim. +# +# Run: python examples/decision-request.py (Python >= 3.11) +# Exits non-zero on any mismatch. +from __future__ import annotations + +import json +import sys +from pathlib import Path + +try: + from interlock_protocol.canonical import canonical_bytes + from interlock_protocol.protocol import EffectRequest, Policy, TargetResource +except ModuleNotFoundError: # pragma: no cover - convenience for uninstalled runs + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + from interlock_protocol.canonical import canonical_bytes + from interlock_protocol.protocol import EffectRequest, Policy, TargetResource + +# The corpus lives three directories up from clients/python/examples. +CORPUS = Path(__file__).resolve().parent.parent.parent.parent / "conformance" / "compat" / "v0.1.0" + + +def resolve_resource(policy: Policy, resource_id: str) -> TargetResource: + """Python mirror of Go's ir.Policy.ResolveResource. + + Name the resource once (in the policy) and look up its URI+kind here, rather + than restating them at the call site. It fails closed exactly like Go: an + unknown id raises (no default) and a duplicate id raises (no arbitrary pick), + because a silently-wrong target is the one mistake a policy engine must never + make. + """ + matches = [r for r in policy["resources"] if r["id"] == resource_id] + if not matches: + raise ValueError( + f'interlock: resource id "{resource_id}" not declared in policy "{policy["policy_id"]}"' + ) + if len(matches) > 1: + raise ValueError( + f'interlock: resource id "{resource_id}" declared more than once in policy "{policy["policy_id"]}"' + ) + return {"kind": matches[0]["kind"], "uri": matches[0]["uri"]} + + +def build_effect_request( + policy: Policy, + *, + run_id: str, + request_id: str, + actor: str, + operation: str, + resource_id: str, + claimed_policy_hash: str | None = None, + evidence: list | None = None, + source: str = "interlock-python-client", + fidelity: str = "brokered", +) -> EffectRequest: + """Assemble the typed EffectRequest, resolving the resource by id. + + Pure: no I/O, no decision. This is the exact value that would be canonically + encoded and sent to the Go controller. Observation defaults to a brokered + decision-transport reading, matching the Go client (client.EffectRequest). + """ + req: EffectRequest = { + "protocol": "interlock.effect.v1", + "request_id": request_id, + "run_id": run_id, + "actor": actor, + "operation": operation, + "resource": resolve_resource(policy, resource_id), + "observation": {"source": source, "fidelity": fidelity}, + } + if claimed_policy_hash is not None: + req["claimed_policy_hash"] = claimed_policy_hash + if evidence is not None: + req["evidence"] = evidence + return req + + +def main() -> int: + policy: Policy = json.loads( + (CORPUS / "policies" / "exclusive-publish.json").read_text(encoding="utf-8") + ) + + failures = 0 + + def check(ok: bool, msg: str) -> None: + nonlocal failures + print(f"{'PASS' if ok else 'FAIL'} {msg}") + if not ok: + failures += 1 + + # The win: the publisher publishes the "artifact" resource named by id. The URI + # and kind are never typed by the caller — they come from the policy. + req = build_effect_request( + policy, + run_id="run1", + request_id="r1", + actor="publisher", + operation="artifact.publish", + resource_id="artifact", + claimed_policy_hash="sha256:edf4ed0d10b1aa9c0c2a0301688b3c97e34f6c0fc78502f4303466adb4ea82b3", + evidence=[ + {"kind": "staged_hash_match"}, + {"kind": "receipt_status", "receipt": "deltawire.supervision.receipt.v1", "status": "released"}, + ], + ) + + check( + req["resource"]["uri"] == "repo://out/result.json" and req["resource"]["kind"] == "file", + "resource resolved from policy id (URI/kind never restated at call site)", + ) + check(req["observation"]["fidelity"] == "brokered", "observation defaults to a brokered decision reading") + check(req["protocol"] == "interlock.effect.v1", "protocol tag stamped") + + # Canonically encodable — the same deterministic bytes Go and TypeScript + # produce, ready to transport. Byte-for-byte cross-language parity is proven by + # parity.py against the frozen corpus. + wire = canonical_bytes(req) + check(len(wire) > 0, "typed request canonically encodes (no hand-written JSON)") + + # Fail-closed: an undeclared resource id is an error, never a silent empty target. + threw = False + try: + build_effect_request( + policy, run_id="r", request_id="r", actor="publisher", + operation="artifact.publish", resource_id="nope", + ) + except ValueError: + threw = True + check(threw, "unknown resource id fails closed (mirrors Go ir.ResolveResource)") + + print("\nRESULT: PASS" if failures == 0 else f"\nRESULT: FAIL ({failures})") + return 0 if failures == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/clients/typescript/examples/decision-request.ts b/clients/typescript/examples/decision-request.ts new file mode 100644 index 0000000..5dcfd88 --- /dev/null +++ b/clients/typescript/examples/decision-request.ts @@ -0,0 +1,131 @@ +// decision-request.ts shows the winning cross-language ergonomic in TypeScript: +// a caller names the resource by its POLICY ID and gets a typed EffectRequest with +// the URI+kind resolved from the policy — never restating a resource's URI/kind and +// never hand-writing the request JSON. The request is emitted with the same +// canonical encoder the hash is computed from. +// +// Authority boundary — read this before extending the example. This client does +// NOT decide and does NOT enforce. It builds a typed, canonically-encoded request +// DTO. The decision is made by the Go engine (behind the interlock-pitot +// controller), and the protected effect is performed by the Go broker. Porting the +// decision engine or the broker into TypeScript is an explicit non-goal: the +// canonical IR is the execution language, Go is the trusted executable. So this +// file resolves a resource and shapes a request; it makes no allow/deny claim. +// +// Run: node examples/decision-request.ts (Node >= 22, native type stripping) +// Exits non-zero on any mismatch. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { canonicalBytes } from "../src/canonical.ts"; +import type { + EffectRequest, + Fidelity, + Operation, + Policy, + TargetResource, +} from "../src/protocol.ts"; + +// resolveResource is the TS mirror of Go's ir.Policy.ResolveResource: name the +// resource once (in the policy) and look up its URI+kind here, rather than +// restating them at the call site. It fails closed exactly like Go — an unknown id +// throws (no default) and a duplicate id throws (no arbitrary pick) — because a +// silently-wrong target is the one mistake a policy engine must never make. +function resolveResource(policy: Policy, id: string): TargetResource { + const matches = policy.resources.filter((r) => r.id === id); + if (matches.length === 0) { + throw new Error(`interlock: resource id "${id}" not declared in policy "${policy.policy_id}"`); + } + if (matches.length > 1) { + throw new Error(`interlock: resource id "${id}" declared more than once in policy "${policy.policy_id}"`); + } + return { kind: matches[0].kind, uri: matches[0].uri }; +} + +// DecisionInput is the small, honest set of facts a caller supplies. The resource +// is named by id; observation defaults to a brokered decision-transport reading, +// matching the Go client (client.EffectRequest). +interface DecisionInput { + runId: string; + requestId: string; + actor: string; + operation: Operation; + resourceId: string; + claimedPolicyHash?: string; + evidence?: EffectRequest["evidence"]; + source?: string; + fidelity?: Fidelity; +} + +// buildEffectRequest assembles the typed EffectRequest, resolving the resource by +// id. It is pure: no I/O, no decision. This is the exact value that would be +// canonically encoded and sent to the Go controller. +function buildEffectRequest(policy: Policy, input: DecisionInput): EffectRequest { + return { + protocol: "interlock.effect.v1", + request_id: input.requestId, + run_id: input.runId, + actor: input.actor, + operation: input.operation, + resource: resolveResource(policy, input.resourceId), + observation: { + source: input.source ?? "interlock-ts-client", + fidelity: input.fidelity ?? "brokered", + }, + ...(input.claimedPolicyHash ? { claimed_policy_hash: input.claimedPolicyHash } : {}), + ...(input.evidence ? { evidence: input.evidence } : {}), + }; +} + +// The corpus lives three directories up from clients/typescript/examples. +const CORPUS = join(import.meta.dirname, "..", "..", "..", "conformance", "compat", "v0.1.0"); +const policy = JSON.parse( + readFileSync(join(CORPUS, "policies", "exclusive-publish.json"), "utf8"), +) as Policy; + +let failures = 0; +function check(ok: boolean, msg: string): void { + console.log(`${ok ? "PASS" : "FAIL"} ${msg}`); + if (!ok) failures++; +} + +// The win: the publisher publishes the "artifact" resource named by id. The URI +// and kind are never typed by the caller — they come from the policy. +const req = buildEffectRequest(policy, { + runId: "run1", + requestId: "r1", + actor: "publisher", + operation: "artifact.publish", + resourceId: "artifact", + claimedPolicyHash: "sha256:edf4ed0d10b1aa9c0c2a0301688b3c97e34f6c0fc78502f4303466adb4ea82b3", + evidence: [ + { kind: "staged_hash_match" }, + { kind: "receipt_status", receipt: "deltawire.supervision.receipt.v1", status: "released" }, + ], +}); + +check( + req.resource.uri === "repo://out/result.json" && req.resource.kind === "file", + "resource resolved from policy id (URI/kind never restated at call site)", +); +check(req.observation.fidelity === "brokered", "observation defaults to a brokered decision reading"); +check(req.protocol === "interlock.effect.v1", "protocol tag stamped"); + +// The request is canonically encodable — the same deterministic bytes Go and +// Python produce, ready to transport. We assert it encodes without throwing and is +// non-empty; byte-for-byte cross-language parity is proven by parity.ts against the +// frozen corpus. +const wire = canonicalBytes(req); +check(wire.length > 0, "typed request canonically encodes (no hand-written JSON)"); + +// Fail-closed: an undeclared resource id is an error, never a silent empty target. +let threw = false; +try { + buildEffectRequest(policy, { runId: "r", requestId: "r", actor: "publisher", operation: "artifact.publish", resourceId: "nope" }); +} catch { + threw = true; +} +check(threw, "unknown resource id fails closed (mirrors Go ir.ResolveResource)"); + +console.log(failures === 0 ? "\nRESULT: PASS" : `\nRESULT: FAIL (${failures})`); +process.exit(failures === 0 ? 0 : 1); diff --git a/examples/release-manifest/publish_test.go b/examples/release-manifest/publish_test.go index b8b7f05..6a8c9f3 100644 --- a/examples/release-manifest/publish_test.go +++ b/examples/release-manifest/publish_test.go @@ -47,14 +47,13 @@ func TestReleaseManifestPublishesViaFacade(t *testing.T) { evidenceDir := filepath.Join(dir, "evidence") res, err := publish.Publish(publish.Request{ - Policy: policy, - RunID: "rel-run", - RequestID: "rel-1", - Actor: "release-bot", - ResourceURI: "repo://dist/release-manifest.json", - Kind: publish.KindFile, - StagedPath: staged, - TargetPath: target, + Policy: policy, + RunID: "rel-run", + RequestID: "rel-1", + Actor: "release-bot", + ResourceID: "manifest", + StagedPath: staged, + TargetPath: target, // Target must not exist yet. Upstream: []publish.Evidence{{ Schema: ReleaseAttestationSchema, @@ -95,14 +94,13 @@ func TestReleaseManifestForeignSchemaFailsClosed(t *testing.T) { target := filepath.Join(dir, "dist", "release-manifest.json") _, err := publish.Publish(publish.Request{ - Policy: policy, - RunID: "rel-run", - RequestID: "rel-2", - Actor: "release-bot", - ResourceURI: "repo://dist/release-manifest.json", - Kind: publish.KindFile, - StagedPath: staged, - TargetPath: target, + Policy: policy, + RunID: "rel-run", + RequestID: "rel-2", + Actor: "release-bot", + ResourceID: "manifest", + StagedPath: staged, + TargetPath: target, Upstream: []publish.Evidence{{ // DeltaWire's schema — foreign to this tenant's policy. Schema: "deltawire.supervision.receipt.v1", diff --git a/ir/ir.go b/ir/ir.go index 78771ee..15f960a 100644 --- a/ir/ir.go +++ b/ir/ir.go @@ -122,6 +122,29 @@ type Resource struct { URI string `json:"uri"` } +// ResolveResource returns the resource declared in the policy with the given ID. +// It reads only the policy (no I/O, no engine), so callers can name a resource +// once — in the policy — instead of restating its URI and kind at every call +// site. It fails closed: an unknown ID is an error (no default), and a duplicate +// ID is an error rather than an arbitrary pick. +func (p Policy) ResolveResource(id string) (Resource, error) { + found := false + var out Resource + for _, r := range p.Resources { + if r.ID != id { + continue + } + if found { + return Resource{}, fmt.Errorf("interlock/ir: resource id %q is declared more than once in policy %q", id, p.PolicyID) + } + out, found = r, true + } + if !found { + return Resource{}, fmt.Errorf("interlock/ir: resource id %q not declared in policy %q", id, p.PolicyID) + } + return out, nil +} + // Requirement is a single evidence predicate attached to an allow rule. type Requirement struct { Kind RequirementKind `json:"kind"` diff --git a/publish/publish.go b/publish/publish.go index 6b618e3..fb56748 100644 --- a/publish/publish.go +++ b/publish/publish.go @@ -65,10 +65,20 @@ type Evidence struct { // ultimately needs, but the tenant supplies them once, by name, instead of // assembling broker.PublishRequest, writing envelopes, and managing a chain. type Request struct { - Policy Policy - RunID string - RequestID string - Actor string + Policy Policy + RunID string + RequestID string + Actor string + + // ResourceID names a resource declared in Policy.Resources. When set, the + // façade resolves ResourceURI and Kind from that declaration, so the tenant + // states the resource once (in the policy) instead of restating its URI and + // kind here — the policy is the single source of truth the broker already + // re-validates against. Leave ResourceURI/Kind empty when using ResourceID; + // if both are given they must agree with the declaration, or the publish + // fails closed (no silent override of the policy). + ResourceID string + ResourceURI string Kind ResourceKind StagedPath string @@ -104,6 +114,25 @@ const upstreamEnvelopeName = "interlock-upstream-envelope.json" // unlike a hand-rolled integration it surfaces — rather than swallows — an // evidence-persistence failure, since an unaudited effect must not report success. func Publish(req Request) (Result, error) { + // Resolve the resource from the policy declaration when a ResourceID is given, + // so the tenant does not restate URI+Kind. This reads the same policy the + // broker re-validates against, changes no canonical bytes, and fails closed on + // a missing/ambiguous ID or a conflicting explicit URI/Kind. + resourceURI, kind := req.ResourceURI, req.Kind + if req.ResourceID != "" { + uri, k, err := ResolveResource(req.Policy, req.ResourceID) + if err != nil { + return Result{}, err + } + if req.ResourceURI != "" && req.ResourceURI != uri { + return Result{}, fmt.Errorf("interlock/publish: ResourceURI %q conflicts with resource %q declared as %q", req.ResourceURI, req.ResourceID, uri) + } + if req.Kind != "" && req.Kind != k { + return Result{}, fmt.Errorf("interlock/publish: Kind %q conflicts with resource %q declared as %q", req.Kind, req.ResourceID, k) + } + resourceURI, kind = uri, k + } + staged, err := os.ReadFile(req.StagedPath) if err != nil { return Result{}, fmt.Errorf("interlock/publish: read staged candidate: %w", err) @@ -131,8 +160,8 @@ func Publish(req Request) (Result, error) { RunID: req.RunID, RequestID: req.RequestID, Actor: req.Actor, - ResourceURI: req.ResourceURI, - Kind: req.Kind, + ResourceURI: resourceURI, + Kind: kind, StagedPath: req.StagedPath, TargetPath: req.TargetPath, ExpectedTargetHash: req.ExpectedTargetHash, @@ -179,6 +208,24 @@ func persistEvidence(dir string, res broker.Result, chain *receipt.Chain) error return nil } +// ResolveResource looks up a resource declared in the policy by its ID and +// returns its URI and kind. It lets a tenant name a resource once — in the +// policy — instead of restating URI+Kind at every publish call. It reads only +// the policy (no I/O, no engine), so it never weakens the broker, which still +// re-validates the resolved URI against the same policy at decide time. +// +// It fails closed: an unknown ID is an error (no default), and a duplicate ID is +// an error rather than an arbitrary pick. +func ResolveResource(p Policy, resourceID string) (uri string, kind ResourceKind, err error) { + // Delegate to the pure ir resolver so the decision-transport path can resolve + // resources without importing this (broker-carrying) façade package. + r, err := p.ResolveResource(resourceID) + if err != nil { + return "", "", err + } + return r.URI, r.Kind, nil +} + // TargetHashOf returns the current target's content hash in Interlock's tagged // format, or "" if the target does not exist yet — the value to pass as // Request.ExpectedTargetHash to adopt the current state (or require absence). diff --git a/publish/publish_test.go b/publish/publish_test.go new file mode 100644 index 0000000..531365d --- /dev/null +++ b/publish/publish_test.go @@ -0,0 +1,102 @@ +package publish + +import ( + "strings" + "testing" + + "github.com/operatorstack/interlock/ir" +) + +// testPolicy is a minimal policy declaring two distinct resources by ID. It is +// enough to exercise resource resolution without running the engine. +func testPolicy() Policy { + return ir.Policy{ + Protocol: ir.Protocol, + PolicyID: "resolve-test.v1", + Actors: []string{"publisher"}, + Resources: []ir.Resource{ + {ID: "artifact", Kind: ir.KindFile, URI: "repo://out/result.json"}, + {ID: "workspace", Kind: ir.KindTree, URI: "repo://work/**"}, + }, + } +} + +func TestResolveResource(t *testing.T) { + p := testPolicy() + + uri, kind, err := ResolveResource(p, "artifact") + if err != nil { + t.Fatalf("resolve artifact: %v", err) + } + if uri != "repo://out/result.json" || kind != KindFile { + t.Fatalf("resolve artifact = (%q,%q), want (repo://out/result.json,file)", uri, kind) + } + + if _, _, err := ResolveResource(p, "workspace"); err != nil { + t.Fatalf("resolve workspace: %v", err) + } +} + +func TestResolveResourceUnknownIDFailsClosed(t *testing.T) { + _, _, err := ResolveResource(testPolicy(), "does-not-exist") + if err == nil { + t.Fatal("resolving an undeclared resource id must fail closed") + } + if !strings.Contains(err.Error(), "not declared") { + t.Fatalf("error = %q, want it to mention the id is not declared", err) + } +} + +func TestResolveResourceDuplicateIDFailsClosed(t *testing.T) { + p := testPolicy() + p.Resources = append(p.Resources, ir.Resource{ID: "artifact", Kind: ir.KindFile, URI: "repo://other.json"}) + + _, _, err := ResolveResource(p, "artifact") + if err == nil { + t.Fatal("a duplicate resource id must fail closed rather than pick arbitrarily") + } + if !strings.Contains(err.Error(), "more than once") { + t.Fatalf("error = %q, want it to mention the duplicate", err) + } +} + +// Resolution runs before any filesystem I/O, so an unknown ResourceID surfaces +// even with a bogus staged path — proving the request never reaches the broker. +func TestPublishUnknownResourceIDFailsBeforeIO(t *testing.T) { + _, err := Publish(Request{ + Policy: testPolicy(), + RunID: "r1", + RequestID: "req1", + Actor: "publisher", + ResourceID: "missing", + StagedPath: "/nonexistent/staged", + TargetPath: "/nonexistent/target", + }) + if err == nil { + t.Fatal("publish with an undeclared ResourceID must fail closed") + } + if !strings.Contains(err.Error(), "not declared") { + t.Fatalf("error = %q, want a resolution error, not an I/O error", err) + } +} + +// An explicit ResourceURI that disagrees with the resolved declaration must fail +// closed — the façade never silently overrides the policy's source of truth. +func TestPublishConflictingExplicitURIFailsClosed(t *testing.T) { + _, err := Publish(Request{ + Policy: testPolicy(), + RunID: "r1", + RequestID: "req1", + Actor: "publisher", + ResourceID: "artifact", + ResourceURI: "repo://out/WRONG.json", + StagedPath: "/nonexistent/staged", + TargetPath: "/nonexistent/target", + }) + if err == nil { + t.Fatal("a conflicting explicit ResourceURI must fail closed") + } + if !strings.Contains(err.Error(), "conflicts") { + t.Fatalf("error = %q, want a conflict error", err) + } +}