From 1aacba8419c6807e59eded47fa51999052b9e629 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 02:21:59 +0100 Subject: [PATCH 001/115] docs: bound project checker model runtime and recovery --- .commitrail/changes/checker-model-runtime.md | 174 +++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 .commitrail/changes/checker-model-runtime.md diff --git a/.commitrail/changes/checker-model-runtime.md b/.commitrail/changes/checker-model-runtime.md new file mode 100644 index 000000000..97ebe14b4 --- /dev/null +++ b/.commitrail/changes/checker-model-runtime.md @@ -0,0 +1,174 @@ +# Project-image model calls with bounded spend and durable recovery + +- Initiative: None +- Durable disposition: Planned +- Intended merge outcome: Give policy-bound external POST images controlled model access, with project spend custody, immutable evidence and recovery that cannot silently repeat a paid call. + +## Intent + +The user explicitly resumed the minimum PILOT-05 runtime needed by the real +Terminal Systems project. Six proposed POST specifications require model review. +The image owns the rubric, prompt and interpretation; Workstream owns authority, +spend and lifecycle. No project-specific rule belongs in the shared Rust runner. + +## Current behavior + +`selected_post_submit_execution.py` executes locked images over an ART callback. +The Rust service exposes only read-only verified submission material in a +networkless container. There is no model credential, project spend ledger or +provider-call recovery. `execution_coordination.py` creates a successor checker +generation on Operator retry; using that generation as a paid-call key would +permit duplicate charges. ART already defines generated checker output custody +ports, but their production reservation path needs inspection before reuse. + +## Bounded change + +### Allowed + +- CHECKERS model-call contracts, models, repository/service operations and + selected POST integration; existing AUTH and TASK ports for fresh execution + authority/currentness and Operator provisioning. +- Existing ART generated checker-output custody to retain bounded private + request/response evidence, with matching authority and read concealment. +- Typed provider capability under `backend/app/interfaces/`, concrete adapter + and explicit ADR-0014 composition; fixed settings outside images. +- One linear Alembic continuation with preflight, intermediate upgrade and + retained-data proof; affected schema snapshots, permission catalogues and MCP + contracts updated together where changed. +- Shared Rust runner and SDK: invocation-local socket transport only, preserving + network denial, read-only submission material, cleanup and output budgets. +- Focused PostgreSQL/MinIO, transport, provider and public lifecycle tests; + affected CI allocation, owner specifications, runbook and roadmap. + +### Not allowed + +No provider credential or global proxy socket in project images; no contributor +billing, setup-agent billing, alternate checker implementation kind, project +rules in Rust, unrestricted provider/model selection, automatic paid retries +after ambiguous delivery, task/revision spending on infrastructure failures, +or live model spend before the user supplies the rehearsal cap. No merge or +hosted deployment is authorized. Execution trials and prior-revision material +remain separately necessary before the entire project can activate. + +## Design and decisions + +An immutable project model profile binds the exact registry entry and digest, provider/API/model +snapshot, generation parameters, token/deadline ceilings, one allowed call slot +and immutable integer pricing. Reserve the complete selector triple +`model_profile_id`, `model_profile_version`, `model_profile_digest` in the flat +PM-approved image configuration. A typed exact profile reader checks project, +registry and profile identity during policy compilation, readiness/activation and +runtime. Partial selectors fail; the existing POST policy hash retains this +custody without a new checker kind or TASK lineage column. +An Operator sets a finite project budget; profiles do not +allow images to change that budget or choose a different model. Monetary amounts +use integer USD nanodollars with explicit immutable pricing provenance. + +Trusted execution creates a callback-scoped model grant bound to the current +checker lease, exact Submission, requirement, registry/configuration and profile. +Rust exposes only an invocation-local socket. A bounded image request supplies +prompt material; the trusted proxy supplies identity and fixed provider settings. +The global endpoint and opaque grant never enter image input or logs. The backend +verifies a completed call receipt before accepting a model-enabled image verdict. + +The paid slot identity is `(project, assignment, submission, submission_version, +requirement, registry_digest, configuration_hash, profile_version, slot_index)`. +Neither worker lease nor checker retry generation belongs in this key. The first +provider request hash is immutable: a later changed prompt conflicts. Successor +checker runs reuse the same cached or recoverable call. An authorized child +attempt is possible only after a proven no-effect outcome, with a new atomic +reservation; the image cannot select its attempt number. + +Create or attach the stable slot and verify its request evidence first. Reserve +the worst-case allowed input/output charge just before first dispatch, after +fresh authority/currentness, committing reservation and dispatch intent together +before provider I/O. Preserve +AUTH/TASK-before-CHECKERS custody; budget locks must never lead back to TASK. +Concurrent callers serialize the same slot and project budget. Persist dispatch +intent before create, persist provider identity immediately when returned, and +settle measured usage exactly once. Lost create responses remain uncertain and +retain their reservation; absence of a provider ID is not proof of no effect. +Known provider IDs may be retrieved without another create. Cached completion +is immutable and survives process, lease and Operator retries. + +Keep the existing CheckerRun lifecycle. A run may finish `infrastructure_failed` +when its model response is unavailable, while the independent paid call remains +`dispatching` or `uncertain`. Operator retry attaches the same slot; it may only +recover that effect, never create again because a generation changed. A late +response may complete the original call after its run terminalized, allowing the +next current run to consume its cached evidence. Only a proven no-effect child +attempt receives fresh dispatch authority. + +Extend the existing ART generated-output custody with a paid-call-stable +reservation for two bounded canonical JSON slots: request before dispatch and +response/usage on known completion. Do not write transcript bytes to PostgreSQL, +Redis or outbox payloads. Ledger rows contain exact content identities and hashes. +Once dispatch intent is committed, the call-effect owner retains narrowly scoped +authority to finish/recover those exact evidence slots independently of the +original worker lease. It cannot start a new effect under that authority. Fresh +current-run authority links verified call evidence to each consuming run; stale +images cannot spend or gain read access to another invocation. This extends the +same ART put/verify/bind lifecycle, not a parallel artifact store. + +Explicit service actions separate `checker.model_call.dispatch`, +`checker.model_call.recover` and `checker.model_call.complete` under existing +AUTH grants. Dispatch takes exact current execution facts; recover can only +inspect the existing effect; complete takes immutable call-attempt/effect facts +and may retain late evidence. Operator project budget/profile provisioning and +covered-PM cost reads receive their own exact resource/action contracts. + +Implementation order: provider-free profile/slot/budget contracts and PostgreSQL +proof; AUTH and profile approval/readiness; ART evidence custody with MinIO crash +proof; typed fake provider/proxy; invocation-local Rust socket; selected POST +finalization/retry integration; offline project proof; capped live rehearsal. + +Provider output and usage must satisfy the locked bounds. Budget exhaustion, +provider outage, invalid provider response and uncertain delivery are +infrastructure outcomes, never contributor-fixable failures. Private evidence +does not appear in contributor findings or exported provider coordinates. + +## Acceptance criteria + +- One digest-pinned image uses its invocation socket to obtain a bounded model + response while provider credentials and the global endpoint remain unavailable. +- Two concurrent distinct calls cannot exceed the project budget; duplicate + requests yield one provider create and one settlement. +- Provider acceptance followed by response loss, process/lease restart and + Operator checker retry cannot create a second paid call. A generation-keyed + mutant must fail this regression. +- A changed prompt, substituted project/Submission/profile, stale lease, expired + grant, PRE invocation or unentitled image cannot spend. +- Request/response evidence, exact pricing and usage survive restart and replay; + raw content remains under existing private ART custody. +- Budget/provider failure produces infrastructure recovery and no revision + consumption. The existing non-model image path retains its behavior. +- Real PostgreSQL proves locking, rollback, settlement and linear upgrades; + MinIO proves evidence custody. Offline provider tests precede capped live calls. + +## Risk and review routing + +Risk class: L0. Required architecture/plan, authorization/security, database and +QA review, plus CI/docs for affected changes. Human focus: spend authorization, +ambiguous provider effects, immutable policy identity, owner boundaries and +honest separation of local proof from deployed operation. + +## Evidence + +Initial source inspection and architecture review identified the stable paid +slot and the need to preserve image ownership. This is a design record; no +runtime, budget, provider call or live project completion is established yet. + +Plan review found that existing ART output slots are empty and run/lease-bound; +the stable call evidence reservation above is required. It also required exact +profile selection/readiness checks rather than merely hashing opaque config. +The proposed new CheckerRun state was rejected as unnecessary: call-effect +recovery belongs to the durable slot while existing infrastructure outcomes stay +unchanged. + +## Reconciliation + +The earlier PILOT-05 hold is lifted only for this required runtime scope. The +pending rehearsal spend cap blocks live paid POST calls, not implementation or +offline tests. Review current main and open migrations before allocating a +revision number. Required execution trials and authenticated prior-work context +must still be supplied before final guide activation. From 7ab394e762808627cf564301dce07abbaf48fe04 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 02:14:10 +0100 Subject: [PATCH 002/115] fix: enforce exact project metadata paths and fields --- .../terminal-systems-project-checkers.md | 22 ++++++++++++--- docs/roadmap_status.md | 10 ++++--- pilots/terminal_systems/README.md | 2 +- .../checkers/pre/checker.json | 24 +++++++++++++++++ .../terminal_systems/checkers/pre/checker.py | 17 ++++++++++-- .../pre/samples/nested-field/AUTHOR_NOTES.md | 1 + .../nested-field/environment/Dockerfile | 1 + .../nested-field/environment/README.txt | 1 + .../pre/samples/nested-field/instruction.md | 1 + .../samples/nested-field/solution/README.txt | 1 + .../pre/samples/nested-field/task.toml | 27 +++++++++++++++++++ .../pre/samples/nested-field/tests/README.txt | 1 + .../samples/nested-field/workspace/README.txt | 1 + .../pre/samples/path-alias/AUTHOR_NOTES.md | 1 + .../samples/path-alias/environment/Dockerfile | 1 + .../samples/path-alias/environment/README.txt | 1 + .../pre/samples/path-alias/instruction.md | 1 + .../samples/path-alias/solution/README.txt | 1 + .../checkers/pre/samples/path-alias/task.toml | 26 ++++++++++++++++++ .../pre/samples/path-alias/tests/README.txt | 1 + .../samples/path-alias/workspace/README.txt | 1 + .../checkers/pre/test_checker.py | 25 +++++++++++++++++ 22 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 pilots/terminal_systems/checkers/pre/samples/nested-field/AUTHOR_NOTES.md create mode 100644 pilots/terminal_systems/checkers/pre/samples/nested-field/environment/Dockerfile create mode 100644 pilots/terminal_systems/checkers/pre/samples/nested-field/environment/README.txt create mode 100644 pilots/terminal_systems/checkers/pre/samples/nested-field/instruction.md create mode 100644 pilots/terminal_systems/checkers/pre/samples/nested-field/solution/README.txt create mode 100644 pilots/terminal_systems/checkers/pre/samples/nested-field/task.toml create mode 100644 pilots/terminal_systems/checkers/pre/samples/nested-field/tests/README.txt create mode 100644 pilots/terminal_systems/checkers/pre/samples/nested-field/workspace/README.txt create mode 100644 pilots/terminal_systems/checkers/pre/samples/path-alias/AUTHOR_NOTES.md create mode 100644 pilots/terminal_systems/checkers/pre/samples/path-alias/environment/Dockerfile create mode 100644 pilots/terminal_systems/checkers/pre/samples/path-alias/environment/README.txt create mode 100644 pilots/terminal_systems/checkers/pre/samples/path-alias/instruction.md create mode 100644 pilots/terminal_systems/checkers/pre/samples/path-alias/solution/README.txt create mode 100644 pilots/terminal_systems/checkers/pre/samples/path-alias/task.toml create mode 100644 pilots/terminal_systems/checkers/pre/samples/path-alias/tests/README.txt create mode 100644 pilots/terminal_systems/checkers/pre/samples/path-alias/workspace/README.txt diff --git a/.commitrail/changes/terminal-systems-project-checkers.md b/.commitrail/changes/terminal-systems-project-checkers.md index 13dcd3737..f061ebbc0 100644 --- a/.commitrail/changes/terminal-systems-project-checkers.md +++ b/.commitrail/changes/terminal-systems-project-checkers.md @@ -4,7 +4,7 @@ - Durable disposition: Planned - Intended merge outcome: Implement the source-backed Terminal Systems checker specifications as project-owned images, with the missing generic runtime capabilities explicitly qualified before policy activation. -## Intent and source +## Intent Continue after merged setup PR #651 on main `3ff69e3a8738fc12e7e8bb9d0f37583980409734`. The original guide and final reviewed setup proposal define two PRE checks, six @@ -13,7 +13,7 @@ This is a real guide-driven project, not a fixed task-name fixture. The user explicitly resumed the minimal budgeted model runtime required by this project; the prior PILOT-05 hold does not prohibit this authorized scope. -## First bounded implementation +## Bounded change Allowed paths are `pilots/terminal_systems/checkers/**`, the project README, affected current roadmap/runbook claims, this record and proportionate test/CI @@ -38,7 +38,7 @@ untrusted author claims, fixed answers, or manual reviewer findings. Setup-model spend integration is not implicitly included. No merge or hosted deployment is authorized by this record. -## Acceptance and verification +## Acceptance criteria - PRE layout checks required regular files and directories at the package root, accepting arbitrary task subjects and additional useful directories. @@ -59,6 +59,8 @@ authorized by this record. no Submission, durable post-checking, revision/infrastructure recovery and exact governed contribution evidence. Image samples alone are not that proof. +## Risk and review routing + Risk class: L1. Required focused reviews: architecture/plan, security and QA/test delta; CI review only for changes to CI selection. Human focus: real rule coverage, generic runtime boundaries, exact cost/custody, and honest qualification limits. @@ -70,3 +72,17 @@ requirement identity. The same registered capability can safely serve the two requirements with distinct locked configurations and separately counted budgets. The initial PRE plan has no architecture blocker. Generic POST prerequisites remain under design review; missing runtime support blocks activation. + +## Evidence + +Independent review identified noncanonical metadata path aliases and undeclared +fields in the four fixed metadata tables. Two regression cases reproduced eight +incorrect passes before repair. Paths now preserve exact manifest spelling and +supplementary metadata belongs under `extensions`; arbitrary nested extension +data remains supported. The image sample suite includes both rejection cases. +The affected roadmap and project documentation distinguish the retained ten +unbound suggestions, implemented PRE rules, and remaining eight POST rules. + +Seventeen rule tests pass. Image qualification uses the actual networkless Docker +runner and strict public request/result contracts; it does not establish gVisor, +registration, policy approval or a completed contribution journey. diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 25ec3176d..e3090a2a7 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -1524,10 +1524,12 @@ Exact stored/public result and component hashes and CLI rendering passed; all 57 accumulated provider resources were deleted. Earlier proposals remain immutable and unapproved. This qualification required explicit PM corrections and establishes one reviewed handoff, not automatic completeness for arbitrary guides. Ten required -checks remain capability gaps with no bindings. Their images and runtime/material -dependencies, registration, corrected policy binding and approval are the next -boundary; no new image, approved binding, guide activation or contribution outcome -for these inputs is established. +checks remain unbound suggestions in that immutable proposal. The project-owned +PRE image now implements both PRE specifications and is locally qualified; it is +not yet published, registered or bound. The eight POST checks and their model, +execution and authenticated-material dependencies remain to be implemented. +Registration, corrected policy binding and approval must precede activation; +no approved binding, activated guide or contribution outcome is established. The [offline gVisor build spike](engineering/pilot00-gvisor-offline-build-spike.md) selects a sealed-cache Kaniko builder plus a separate gVisor oracle sandbox for diff --git a/pilots/terminal_systems/README.md b/pilots/terminal_systems/README.md index 8ddf907a5..3028ace55 100644 --- a/pilots/terminal_systems/README.md +++ b/pilots/terminal_systems/README.md @@ -117,7 +117,7 @@ Its samples are structural fixtures, not completed authoring submissions. The eight POST checks and their model, isolated execution and authenticated material dependencies remain unavailable. No registered image, approved policy, activated guide or contribution outcome is claimed. Engineering -must implement the proposed checks and resolve their runtime/material gaps, +must implement the remaining eight POST checks and resolve their runtime/material gaps, register the exact images, and obtain corrected bindings and PM approval before activation and the contributor journey. See the [live setup record](../../.commitrail/changes/terminal-systems-live-setup.md) diff --git a/pilots/terminal_systems/checkers/pre/checker.json b/pilots/terminal_systems/checkers/pre/checker.json index 19df433fd..75126e832 100644 --- a/pilots/terminal_systems/checkers/pre/checker.json +++ b/pilots/terminal_systems/checkers/pre/checker.json @@ -84,6 +84,30 @@ "check": "metadata_contract" }, "input": {} + }, + { + "name": "metadata-path-alias", + "directory": "samples/path-alias", + "expected": "failed", + "expected_codes": [ + "metadata_unsafe_path" + ], + "configuration": { + "check": "metadata_contract" + }, + "input": {} + }, + { + "name": "metadata-nested-field", + "directory": "samples/nested-field", + "expected": "failed", + "expected_codes": [ + "metadata_invalid_format" + ], + "configuration": { + "check": "metadata_contract" + }, + "input": {} } ] } diff --git a/pilots/terminal_systems/checkers/pre/checker.py b/pilots/terminal_systems/checkers/pre/checker.py index e75cea6a2..d46d63ff5 100644 --- a/pilots/terminal_systems/checkers/pre/checker.py +++ b/pilots/terminal_systems/checkers/pre/checker.py @@ -119,7 +119,7 @@ def contained_path(root: Path, value: object, expected: str) -> bool: if value == ".": return expected == "directory" parts = value.split("/") - if value.startswith("/") or ":" in parts[0] or ".." in parts: + if ":" in parts[0] or any(part in ("", ".", "..") for part in parts): return False return entry_type(root, value) == expected @@ -195,10 +195,23 @@ def required(condition: bool, field: str) -> None: required(isinstance(data.get("title"), str) and bool(data["title"]), "title") tables = {} - for name in ("environment", "reference", "verification", "resources"): + fields_by_table = { + "environment": {"dockerfile", "context", "platform"}, + "reference": {"command", "working_directory"}, + "verification": {"command", "working_directory"}, + "resources": set(RESOURCE_FIELDS), + } + for name, fields in fields_by_table.items(): value = data.get(name) required(isinstance(value, dict), name) tables[name] = value if isinstance(value, dict) else {} + if set(tables[name]) - fields: + findings.append( + issue( + "metadata_invalid_format", + f"Use only documented fields in {name}; put supplementary metadata under extensions.", + ) + ) platform = tables["environment"].get("platform") required( isinstance(platform, str) diff --git a/pilots/terminal_systems/checkers/pre/samples/nested-field/AUTHOR_NOTES.md b/pilots/terminal_systems/checkers/pre/samples/nested-field/AUTHOR_NOTES.md new file mode 100644 index 000000000..e88f510d2 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/nested-field/AUTHOR_NOTES.md @@ -0,0 +1 @@ +Structural PRE fixture. Semantic and execution checks have not run. diff --git a/pilots/terminal_systems/checkers/pre/samples/nested-field/environment/Dockerfile b/pilots/terminal_systems/checkers/pre/samples/nested-field/environment/Dockerfile new file mode 100644 index 000000000..c35f1b5f5 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/nested-field/environment/Dockerfile @@ -0,0 +1 @@ +FROM scratch diff --git a/pilots/terminal_systems/checkers/pre/samples/nested-field/environment/README.txt b/pilots/terminal_systems/checkers/pre/samples/nested-field/environment/README.txt new file mode 100644 index 000000000..6f7f0dbe2 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/nested-field/environment/README.txt @@ -0,0 +1 @@ +Structural PRE fixture only. Not a completed authoring submission. diff --git a/pilots/terminal_systems/checkers/pre/samples/nested-field/instruction.md b/pilots/terminal_systems/checkers/pre/samples/nested-field/instruction.md new file mode 100644 index 000000000..e88f510d2 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/nested-field/instruction.md @@ -0,0 +1 @@ +Structural PRE fixture. Semantic and execution checks have not run. diff --git a/pilots/terminal_systems/checkers/pre/samples/nested-field/solution/README.txt b/pilots/terminal_systems/checkers/pre/samples/nested-field/solution/README.txt new file mode 100644 index 000000000..6f7f0dbe2 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/nested-field/solution/README.txt @@ -0,0 +1 @@ +Structural PRE fixture only. Not a completed authoring submission. diff --git a/pilots/terminal_systems/checkers/pre/samples/nested-field/task.toml b/pilots/terminal_systems/checkers/pre/samples/nested-field/task.toml new file mode 100644 index 000000000..f660efa35 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/nested-field/task.toml @@ -0,0 +1,27 @@ +# Shape example only: these paths and allowances are not a runnable task. +# Each author supplies real files, appropriate commands and measured trial notes. +format_version = 1 +id = "portable-intake-example" +title = "Repair a documented local tool" + +[environment] +undeclared = "value" +dockerfile = "environment/Dockerfile" +context = "." +platform = "linux/amd64" + +[reference] +command = ["sh", "solution/apply.sh"] +working_directory = "." + +[verification] +command = ["sh", "tests/run.sh"] +working_directory = "." + +[resources] +cpu_millis = 1000 +memory_bytes = 536870912 +disk_bytes = 1073741824 +build_timeout_seconds = 300 +reference_timeout_seconds = 120 +verification_timeout_seconds = 120 diff --git a/pilots/terminal_systems/checkers/pre/samples/nested-field/tests/README.txt b/pilots/terminal_systems/checkers/pre/samples/nested-field/tests/README.txt new file mode 100644 index 000000000..6f7f0dbe2 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/nested-field/tests/README.txt @@ -0,0 +1 @@ +Structural PRE fixture only. Not a completed authoring submission. diff --git a/pilots/terminal_systems/checkers/pre/samples/nested-field/workspace/README.txt b/pilots/terminal_systems/checkers/pre/samples/nested-field/workspace/README.txt new file mode 100644 index 000000000..6f7f0dbe2 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/nested-field/workspace/README.txt @@ -0,0 +1 @@ +Structural PRE fixture only. Not a completed authoring submission. diff --git a/pilots/terminal_systems/checkers/pre/samples/path-alias/AUTHOR_NOTES.md b/pilots/terminal_systems/checkers/pre/samples/path-alias/AUTHOR_NOTES.md new file mode 100644 index 000000000..e88f510d2 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/path-alias/AUTHOR_NOTES.md @@ -0,0 +1 @@ +Structural PRE fixture. Semantic and execution checks have not run. diff --git a/pilots/terminal_systems/checkers/pre/samples/path-alias/environment/Dockerfile b/pilots/terminal_systems/checkers/pre/samples/path-alias/environment/Dockerfile new file mode 100644 index 000000000..c35f1b5f5 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/path-alias/environment/Dockerfile @@ -0,0 +1 @@ +FROM scratch diff --git a/pilots/terminal_systems/checkers/pre/samples/path-alias/environment/README.txt b/pilots/terminal_systems/checkers/pre/samples/path-alias/environment/README.txt new file mode 100644 index 000000000..6f7f0dbe2 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/path-alias/environment/README.txt @@ -0,0 +1 @@ +Structural PRE fixture only. Not a completed authoring submission. diff --git a/pilots/terminal_systems/checkers/pre/samples/path-alias/instruction.md b/pilots/terminal_systems/checkers/pre/samples/path-alias/instruction.md new file mode 100644 index 000000000..e88f510d2 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/path-alias/instruction.md @@ -0,0 +1 @@ +Structural PRE fixture. Semantic and execution checks have not run. diff --git a/pilots/terminal_systems/checkers/pre/samples/path-alias/solution/README.txt b/pilots/terminal_systems/checkers/pre/samples/path-alias/solution/README.txt new file mode 100644 index 000000000..6f7f0dbe2 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/path-alias/solution/README.txt @@ -0,0 +1 @@ +Structural PRE fixture only. Not a completed authoring submission. diff --git a/pilots/terminal_systems/checkers/pre/samples/path-alias/task.toml b/pilots/terminal_systems/checkers/pre/samples/path-alias/task.toml new file mode 100644 index 000000000..d8e515987 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/path-alias/task.toml @@ -0,0 +1,26 @@ +# Shape example only: these paths and allowances are not a runnable task. +# Each author supplies real files, appropriate commands and measured trial notes. +format_version = 1 +id = "portable-intake-example" +title = "Repair a documented local tool" + +[environment] +dockerfile = "environment//Dockerfile" +context = "." +platform = "linux/amd64" + +[reference] +command = ["sh", "solution/apply.sh"] +working_directory = "." + +[verification] +command = ["sh", "tests/run.sh"] +working_directory = "." + +[resources] +cpu_millis = 1000 +memory_bytes = 536870912 +disk_bytes = 1073741824 +build_timeout_seconds = 300 +reference_timeout_seconds = 120 +verification_timeout_seconds = 120 diff --git a/pilots/terminal_systems/checkers/pre/samples/path-alias/tests/README.txt b/pilots/terminal_systems/checkers/pre/samples/path-alias/tests/README.txt new file mode 100644 index 000000000..6f7f0dbe2 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/path-alias/tests/README.txt @@ -0,0 +1 @@ +Structural PRE fixture only. Not a completed authoring submission. diff --git a/pilots/terminal_systems/checkers/pre/samples/path-alias/workspace/README.txt b/pilots/terminal_systems/checkers/pre/samples/path-alias/workspace/README.txt new file mode 100644 index 000000000..6f7f0dbe2 --- /dev/null +++ b/pilots/terminal_systems/checkers/pre/samples/path-alias/workspace/README.txt @@ -0,0 +1 @@ +Structural PRE fixture only. Not a completed authoring submission. diff --git a/pilots/terminal_systems/checkers/pre/test_checker.py b/pilots/terminal_systems/checkers/pre/test_checker.py index 3b3f07079..54c5c7cc8 100644 --- a/pilots/terminal_systems/checkers/pre/test_checker.py +++ b/pilots/terminal_systems/checkers/pre/test_checker.py @@ -109,6 +109,31 @@ def test_links_do_not_escape_verified_tree(self): (self.root / "task.toml").symlink_to(outside / "Dockerfile") self.assertEqual(self.codes(), {"metadata_invalid_format"}) + def test_metadata_rejects_noncanonical_path_aliases(self): + path = self.root / "task.toml" + original = path.read_text() + for alias in ( + "./environment/Dockerfile", + "environment/./Dockerfile", + "environment//Dockerfile", + "environment/Dockerfile/", + ): + with self.subTest(alias=alias): + path.write_text(original.replace("environment/Dockerfile", alias)) + self.assertEqual(self.codes(), {"metadata_unsafe_path"}) + + def test_metadata_rejects_undeclared_nested_fields(self): + path = self.root / "task.toml" + original = path.read_text() + for table in ("environment", "reference", "verification", "resources"): + with self.subTest(table=table): + path.write_text( + original.replace(f"[{table}]", f'[{table}]\nundeclared = "value"') + ) + self.assertEqual(self.codes(), {"metadata_invalid_format"}) + path.write_text(original + '\n[extensions.custom]\nextra = "allowed"\n') + self.assertEqual(checker.metadata(self.root), []) + def test_version_bool_and_zero_resource_are_not_integers(self): self.change("format_version = 1", "format_version = true") self.change("cpu_millis = 1000", "cpu_millis = 0") From 07a0c208e4f8abe8f00eeeb299ad5da34d3a9479 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 03:58:48 +0100 Subject: [PATCH 003/115] wip: compose budgeted project checker model runtime --- .commitrail/changes/checker-model-runtime.md | 39 +- backend/alembic/env.py | 3 +- .../versions/0059_checker_model_runtime.py | 1624 +++++++++++++++++ backend/app/adapters/artifacts/__init__.py | 33 +- backend/app/adapters/auth/__init__.py | 43 +- backend/app/adapters/checkers/__init__.py | 106 +- .../app/adapters/checkers/external_service.py | 17 + .../app/adapters/checkers/model_execution.py | 98 + .../app/adapters/checkers/model_provider.py | 300 +++ .../checkers/model_proxy_transport.py | 344 ++++ .../app/adapters/checkers/model_runtime.py | 103 ++ backend/app/adapters/projects/__init__.py | 15 +- backend/app/adapters/tasks/__init__.py | 4 +- backend/app/api/deps/checker_models.py | 192 ++ backend/app/core/config.py | 12 + .../app/core/project_guide_instructions.py | 15 +- backend/app/db/models.py | 6 + .../app/interfaces/checker_model_provider.py | 243 +++ backend/app/interfaces/checker_model_proxy.py | 112 ++ .../interfaces/external_checker_execution.py | 23 +- backend/app/interfaces/project_agents.py | 28 +- .../artifacts/checker_model_evidence.py | 113 ++ .../artifacts/checker_output_bindings.py | 35 +- .../artifacts/checker_output_custody.py | 18 +- .../app/modules/artifacts/checker_outputs.py | 96 +- backend/app/modules/artifacts/models.py | 30 +- backend/app/modules/artifacts/schemas.py | 23 +- backend/app/modules/artifacts/service.py | 48 +- .../modules/authorization/admin_schemas.py | 2 +- .../app/modules/authorization/catalogue.py | 147 +- .../checker_model_authorization.py | 348 ++++ .../checker_output_authorization.py | 236 +++ .../authorization/domain/action_groups.py | 22 +- .../app/modules/authorization/domain/audit.py | 6 + .../authorization/domain/checker_models.py | 330 ++++ .../authorization/domain/checker_outputs.py | 262 +++ .../authorization/domain/prepared_service.py | 27 +- backend/app/modules/authorization/kernel.py | 21 +- backend/app/modules/authorization/policy.py | 3 + backend/app/modules/authorization/prepared.py | 53 + .../authorization/prepared_admin_authority.py | 14 +- backend/app/modules/authorization/runtime.py | 5 +- .../modules/checkers/api/external_policy.py | 100 +- .../app/modules/checkers/api/model_calls.py | 608 ++++++ .../app/modules/checkers/api/model_grants.py | 32 + .../modules/checkers/api/model_profiles.py | 102 ++ .../modules/checkers/api/output_custody.py | 112 +- .../checkers/execution_coordination.py | 38 - .../modules/checkers/model_calls/__init__.py | 5 + .../modules/checkers/model_calls/custody.py | 159 ++ .../modules/checkers/model_calls/grants.py | 270 +++ .../modules/checkers/model_calls/models.py | 450 +++++ .../modules/checkers/model_calls/profiles.py | 104 ++ .../app/modules/checkers/model_calls/proxy.py | 322 ++++ .../checkers/model_calls/repository.py | 181 ++ .../modules/checkers/model_calls/service.py | 1135 ++++++++++++ backend/app/modules/checkers/router.py | 94 + .../projects/guide_activation/service.py | 32 +- .../guide_compilation/proposal_approval.py | 11 +- .../guide_compilation/proposal_service.py | 4 + .../post_submit_routing/evaluation_guard.py | 22 + backend/app/workers/checker_model_proxy.py | 31 + backend/scripts/external_api_drill.py | 9 +- backend/scripts/test_lane_catalogue.py | 11 + .../test_admin_reads_postgresql.py | 2 +- .../test_grant_reads_postgresql.py | 2 +- .../tests/authorization/catalogue_fixtures.py | 33 +- .../checker_models/test_contracts.py | 280 +++ backend/tests/authorization/test_catalogue.py | 18 +- .../tests/checker_output_admission_helpers.py | 242 ++- .../tests/checker_output_custody_helpers.py | 98 +- .../checkers/execution/test_execution.py | 18 +- .../external/test_harnessy_proof_checker.py | 44 + .../external/test_model_proxy_transport.py | 356 ++++ .../checkers/external/test_model_transport.py | 113 ++ .../tests/checkers/external/test_policy.py | 320 +++- .../tests/checkers/external/test_runtime.py | 1 + .../model_provider/test_openai_responses.py | 460 +++++ .../tests/checkers_model_calls/conftest.py | 11 + .../test_composed_execution.py | 195 ++ .../test_model_call_contract.py | 181 ++ ...test_model_call_transactions_postgresql.py | 976 ++++++++++ ...test_model_invocation_grants_postgresql.py | 489 +++++ .../test_model_runtime.py | 82 + .../test_proxy_orchestration.py | 334 ++++ .../checkers_model_calls/test_public_api.py | 170 ++ backend/tests/conftest.py | 16 +- .../post_submit_materialization_helpers.py | 2 + .../guide_activation/source_fixtures.py | 30 +- .../test_check_specifications.py | 47 +- .../tests/projects/unified_policy_fixtures.py | 2 + backend/tests/selected_policy_fixtures.py | 2 + backend/tests/test_agent_runtime.py | 3 +- backend/tests/test_alembic.py | 1 + backend/tests/test_artifact_admission.py | 40 +- backend/tests/test_artifact_architecture.py | 8 +- backend/tests/test_checker_output_custody.py | 181 +- backend/tests/test_ci_lane_catalogue.py | 19 +- docker-compose.agent-loop.yml | 42 +- docs/architecture_checker_framework.md | 10 +- docs/operations_checker_models.md | 120 ++ docs/roadmap_status.md | 7 +- external_checkers/README.md | 46 + .../harnessy_proof/publish_local.py | 35 +- .../workstream-checker-service/src/main.rs | 304 ++- .../src/model_relay.rs | 1048 +++++++++++ mcp_server/contracts/permissions_list.json | 13 +- .../tests/integration/test_access_reads.py | 2 +- mcp_server/tests/test_access_reads.py | 4 +- 109 files changed, 14790 insertions(+), 648 deletions(-) create mode 100644 backend/alembic/versions/0059_checker_model_runtime.py create mode 100644 backend/app/adapters/checkers/model_execution.py create mode 100644 backend/app/adapters/checkers/model_provider.py create mode 100644 backend/app/adapters/checkers/model_proxy_transport.py create mode 100644 backend/app/adapters/checkers/model_runtime.py create mode 100644 backend/app/api/deps/checker_models.py create mode 100644 backend/app/interfaces/checker_model_provider.py create mode 100644 backend/app/interfaces/checker_model_proxy.py create mode 100644 backend/app/modules/artifacts/checker_model_evidence.py create mode 100644 backend/app/modules/authorization/checker_model_authorization.py create mode 100644 backend/app/modules/authorization/checker_output_authorization.py create mode 100644 backend/app/modules/authorization/domain/checker_models.py create mode 100644 backend/app/modules/authorization/domain/checker_outputs.py create mode 100644 backend/app/modules/checkers/api/model_calls.py create mode 100644 backend/app/modules/checkers/api/model_grants.py create mode 100644 backend/app/modules/checkers/api/model_profiles.py create mode 100644 backend/app/modules/checkers/model_calls/__init__.py create mode 100644 backend/app/modules/checkers/model_calls/custody.py create mode 100644 backend/app/modules/checkers/model_calls/grants.py create mode 100644 backend/app/modules/checkers/model_calls/models.py create mode 100644 backend/app/modules/checkers/model_calls/profiles.py create mode 100644 backend/app/modules/checkers/model_calls/proxy.py create mode 100644 backend/app/modules/checkers/model_calls/repository.py create mode 100644 backend/app/modules/checkers/model_calls/service.py create mode 100644 backend/app/workers/checker_model_proxy.py create mode 100644 backend/tests/authorization/checker_models/test_contracts.py create mode 100644 backend/tests/checkers/external/test_model_proxy_transport.py create mode 100644 backend/tests/checkers/external/test_model_transport.py create mode 100644 backend/tests/checkers/model_provider/test_openai_responses.py create mode 100644 backend/tests/checkers_model_calls/conftest.py create mode 100644 backend/tests/checkers_model_calls/test_composed_execution.py create mode 100644 backend/tests/checkers_model_calls/test_model_call_contract.py create mode 100644 backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py create mode 100644 backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py create mode 100644 backend/tests/checkers_model_calls/test_model_runtime.py create mode 100644 backend/tests/checkers_model_calls/test_proxy_orchestration.py create mode 100644 backend/tests/checkers_model_calls/test_public_api.py create mode 100644 docs/operations_checker_models.md create mode 100644 external_checkers/workstream-checker-service/src/model_relay.rs diff --git a/.commitrail/changes/checker-model-runtime.md b/.commitrail/changes/checker-model-runtime.md index 97ebe14b4..7efb6dc51 100644 --- a/.commitrail/changes/checker-model-runtime.md +++ b/.commitrail/changes/checker-model-runtime.md @@ -46,7 +46,7 @@ No provider credential or global proxy socket in project images; no contributor billing, setup-agent billing, alternate checker implementation kind, project rules in Rust, unrestricted provider/model selection, automatic paid retries after ambiguous delivery, task/revision spending on infrastructure failures, -or live model spend before the user supplies the rehearsal cap. No merge or +or live model spend beyond the user-confirmed US$10 total rehearsal cap. No merge or hosted deployment is authorized. Execution trials and prior-revision material remain separately necessary before the entire project can activate. @@ -60,6 +60,9 @@ PM-approved image configuration. A typed exact profile reader checks project, registry and profile identity during policy compilation, readiness/activation and runtime. Partial selectors fail; the existing POST policy hash retains this custody without a new checker kind or TASK lineage column. +The user authorized US$10 total for the Terminal Systems rehearsal; configure +10,000,000,000 USD nanodollars and preserve all reservations and settled spend +across retries and configuration versions. No live call may precede enforcement. An Operator sets a finite project budget; profiles do not allow images to change that budget or choose a different model. Monetary amounts use integer USD nanodollars with explicit immutable pricing provenance. @@ -76,8 +79,10 @@ requirement, registry_digest, configuration_hash, profile_version, slot_index)`. Neither worker lease nor checker retry generation belongs in this key. The first provider request hash is immutable: a later changed prompt conflicts. Successor checker runs reuse the same cached or recoverable call. An authorized child -attempt is possible only after a proven no-effect outcome, with a new atomic -reservation; the image cannot select its attempt number. +attempt is possible only after a proven no-effect outcome or an authenticated +terminal provider failure with no usable output and fully settled usage. It +requires explicit Operator recovery authority and a new atomic reservation; +the image cannot select its attempt number. Create or attach the stable slot and verify its request evidence first. Reserve the worst-case allowed input/output charge just before first dispatch, after @@ -96,8 +101,10 @@ when its model response is unavailable, while the independent paid call remains `dispatching` or `uncertain`. Operator retry attaches the same slot; it may only recover that effect, never create again because a generation changed. A late response may complete the original call after its run terminalized, allowing the -next current run to consume its cached evidence. Only a proven no-effect child -attempt receives fresh dispatch authority. +next current run to consume its cached evidence. Only an explicitly authorized +child of a proven no-effect or fully settled terminal failure receives fresh +dispatch authority. An uncertain effect or terminal failure with missing usage +remains ineligible. Extend the existing ART generated-output custody with a paid-call-stable reservation for two bounded canonical JSON slots: request before dispatch and @@ -108,14 +115,28 @@ authority to finish/recover those exact evidence slots independently of the original worker lease. It cannot start a new effect under that authority. Fresh current-run authority links verified call evidence to each consuming run; stale images cannot spend or gain read access to another invocation. This extends the -same ART put/verify/bind lifecycle, not a parallel artifact store. +same ART put/verify/bind lifecycle, not a parallel artifact store. The unused +run-only write selector and deny-only producer composition are replaced; retained +historical rows keep their immutable ancestry. Private call bindings remain +retained independently of the original checker run. + +The local relay root must be mounted at the same Docker-daemon-visible path, +with private same-UID socket custody. gVisor requires the administrator-provided +`host-uds=open` configuration for this mounted socket; Docker development proof +does not qualify gVisor behavior. Explicit service actions separate `checker.model_call.dispatch`, `checker.model_call.recover` and `checker.model_call.complete` under existing AUTH grants. Dispatch takes exact current execution facts; recover can only inspect the existing effect; complete takes immutable call-attempt/effect facts and may retain late evidence. Operator project budget/profile provisioning and -covered-PM cost reads receive their own exact resource/action contracts. +return exact configuration and lifetime budget counters. Broader PM cost-read +surfaces remain in PILOT-05; this bounded runtime exposes Operator controls. + +A dedicated singleton backend process owns the private model listener, rather +than binding it in every API lifespan. Local compose opts into the `model-proxy` +profile. Its graceful drain includes margin beyond the maximum provider deadline; +abrupt termination still retains the original dispatch intent and reservation. Implementation order: provider-free profile/slot/budget contracts and PostgreSQL proof; AUTH and profile approval/readiness; ART evidence custody with MinIO crash @@ -168,7 +189,7 @@ unchanged. ## Reconciliation The earlier PILOT-05 hold is lifted only for this required runtime scope. The -pending rehearsal spend cap blocks live paid POST calls, not implementation or -offline tests. Review current main and open migrations before allocating a +user approved a hard US$10 aggregate rehearsal cap; enforce it before live calls. +Review current main and open migrations before allocating a revision number. Required execution trials and authenticated prior-work context must still be supplied before final guide activation. diff --git a/backend/alembic/env.py b/backend/alembic/env.py index a59910f3e..772517012 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -21,7 +21,7 @@ target_metadata = Base.metadata _BASELINE_REVISION = "0001_uuid7_v01" -_CURRENT_HEAD_REVISION = "0058_compensation_unit_create" +_CURRENT_HEAD_REVISION = "0059_checker_model_runtime" _RECREATE_GUIDANCE = ( "Workstream v0.1 requires a fresh database; recreate this database before " "running the 0001_uuid7_v01 migration" @@ -111,6 +111,7 @@ def do_run_migrations(connection: Connection) -> None: ("0055_delivery_reconciliation",), ("0056_manager_authored_runtime",), ("0057_roles_anywhere_custody",), + ("0058_compensation_unit_create",), (_CURRENT_HEAD_REVISION,), ): raise RuntimeError(_RECREATE_GUIDANCE) diff --git a/backend/alembic/versions/0059_checker_model_runtime.py b/backend/alembic/versions/0059_checker_model_runtime.py new file mode 100644 index 000000000..ad253f947 --- /dev/null +++ b/backend/alembic/versions/0059_checker_model_runtime.py @@ -0,0 +1,1624 @@ +"""Add guarded project-budgeted model-call custody for external checkers.""" + +from alembic import op +from sqlalchemy import text + + +revision = "0059_checker_model_runtime" +down_revision = "0058_compensation_unit_create" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create the CHECKERS ledger before root-owned ART and AUTH extensions.""" + op.execute("SET LOCAL search_path=pg_catalog,public,pg_temp") + for statement in _TABLES: + op.execute(statement) + for statement in _FUNCTIONS: + op.execute(statement) + for statement in _TRIGGERS: + op.execute(statement) + _upgrade_invocation_grants() + _upgrade_art_custody() + _upgrade_authorization_vocabulary() + + +def _upgrade_invocation_grants() -> None: + """Create one-use invocation grants after their retained call-ledger parents.""" + op.execute( + r""" +CREATE TABLE public.checker_model_invocation_grants ( + id uuid NOT NULL, + token_hash varchar(71) NOT NULL, + project_id uuid NOT NULL, + checker_run_id uuid NOT NULL, + registry_entry_id uuid NOT NULL, + execution_facts jsonb NOT NULL, + paid_slot jsonb NOT NULL, + external_request_digest varchar(71) NOT NULL, + expires_at timestamptz NOT NULL, + issued_at timestamptz DEFAULT clock_timestamp() NOT NULL, + authorization_evidence_id uuid NOT NULL, + selected_request_sha256 varchar(71), + selected_attempt_id uuid, + consumed_at timestamptz, + CONSTRAINT pk_checker_model_invocation_grants PRIMARY KEY(id), + CONSTRAINT ck_checker_model_invocation_grants_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_checker_model_invocation_grants_digests CHECK ( + token_hash ~ '^sha256:[0-9a-f]{64}$' AND + external_request_digest ~ '^sha256:[0-9a-f]{64}$' AND + (selected_request_sha256 IS NULL OR + selected_request_sha256 ~ '^sha256:[0-9a-f]{64}$')), + CONSTRAINT ck_checker_model_invocation_grants_json CHECK ( + jsonb_typeof(execution_facts)='object' AND jsonb_typeof(paid_slot)='object'), + CONSTRAINT ck_checker_model_invocation_grants_consumption CHECK ( + (consumed_at IS NULL AND selected_request_sha256 IS NULL AND + selected_attempt_id IS NULL) OR + (consumed_at IS NOT NULL AND selected_request_sha256 IS NOT NULL AND + selected_attempt_id IS NOT NULL)), + CONSTRAINT uq_checker_model_invocation_grants_token UNIQUE(token_hash), + CONSTRAINT uq_checker_model_invocation_grants_authority + UNIQUE(authorization_evidence_id), + CONSTRAINT fk_checker_model_invocation_grants_project FOREIGN KEY(project_id) + REFERENCES public.projects(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_invocation_grants_run FOREIGN KEY(checker_run_id) + REFERENCES public.checker_runs(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_invocation_grants_registry FOREIGN KEY(registry_entry_id) + REFERENCES public.external_checker_registry_entries(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_invocation_grants_authority + FOREIGN KEY(authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_invocation_grants_attempt FOREIGN KEY(selected_attempt_id) + REFERENCES public.checker_model_call_attempts(id) ON DELETE RESTRICT +) + """ + ) + op.execute( + r""" +CREATE INDEX ix_checker_model_invocation_grants_run + ON public.checker_model_invocation_grants(checker_run_id) + """ + ) + op.execute( + r""" +CREATE FUNCTION public.guard_checker_model_invocation_grant() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE authority public.audit_events%ROWTYPE; +DECLARE task_project uuid; +DECLARE assignment_task uuid; +DECLARE assignment_project uuid; +DECLARE submission_task uuid; +DECLARE run public.checker_runs%ROWTYPE; +DECLARE registry public.external_checker_registry_entries%ROWTYPE; +DECLARE profile public.checker_model_profiles%ROWTYPE; +DECLARE attempt public.checker_model_call_attempts%ROWTYPE; +DECLARE slot public.checker_model_call_slots%ROWTYPE; +DECLARE binding_matches integer; +BEGIN + IF TG_OP='DELETE' THEN + RAISE EXCEPTION 'checker model invocation grants cannot be deleted' + USING ERRCODE='55000'; + END IF; + IF TG_OP='UPDATE' THEN + IF OLD.consumed_at IS NOT NULL OR + NEW.selected_request_sha256 IS NULL OR NEW.selected_attempt_id IS NULL OR + NEW.consumed_at IS NULL OR + (to_jsonb(NEW)-ARRAY['selected_request_sha256','selected_attempt_id','consumed_at']) + IS DISTINCT FROM + (to_jsonb(OLD)-ARRAY['selected_request_sha256','selected_attempt_id','consumed_at']) THEN + RAISE EXCEPTION 'checker model invocation grant facts are immutable' + USING ERRCODE='55000'; + END IF; + IF OLD.expires_at<=clock_timestamp() THEN + RAISE EXCEPTION 'checker model invocation grant expired' + USING ERRCODE='23514'; + END IF; + SELECT value.* INTO attempt FROM public.checker_model_call_attempts value + WHERE value.id=NEW.selected_attempt_id FOR SHARE; + SELECT value.* INTO slot FROM public.checker_model_call_slots value + WHERE value.id=attempt.slot_id FOR SHARE; + IF attempt.id IS NULL OR slot.id IS NULL OR + attempt.request_sha256 IS DISTINCT FROM NEW.selected_request_sha256 OR + slot.project_id IS DISTINCT FROM NEW.project_id OR + slot.task_id IS DISTINCT FROM (NEW.execution_facts->>'task_id')::uuid OR + slot.assignment_id IS DISTINCT FROM (NEW.paid_slot->>'assignment_id')::uuid OR + slot.submission_id IS DISTINCT FROM (NEW.paid_slot->>'submission_id')::uuid OR + slot.submission_version IS DISTINCT FROM + (NEW.paid_slot->>'submission_version')::integer OR + slot.requirement_id IS DISTINCT FROM NEW.paid_slot->>'requirement_id' OR + slot.registry_entry_digest IS DISTINCT FROM + NEW.paid_slot->>'registry_entry_digest' OR + slot.configuration_sha256 IS DISTINCT FROM + NEW.paid_slot->>'configuration_sha256' OR + slot.profile_id IS DISTINCT FROM (NEW.paid_slot->>'profile_id')::uuid OR + slot.profile_version IS DISTINCT FROM + (NEW.paid_slot->>'profile_version')::integer OR + slot.profile_digest IS DISTINCT FROM NEW.paid_slot->>'profile_digest' OR + slot.slot_index IS DISTINCT FROM (NEW.paid_slot->>'slot_index')::integer THEN + RAISE EXCEPTION 'checker model invocation grant attempt ancestry is invalid' + USING ERRCODE='23514'; + END IF; + NEW.consumed_at:=clock_timestamp(); + RETURN NEW; + END IF; + + IF jsonb_typeof(NEW.execution_facts)<>'object' OR + jsonb_typeof(NEW.paid_slot)<>'object' OR + (SELECT count(*) FROM jsonb_object_keys(NEW.execution_facts))<>11 OR + NOT NEW.execution_facts ?& ARRAY[ + 'project_id','task_id','assignment_id','submission_id','submission_version', + 'evaluation_request_id','evaluation_generation','checker_run_id', + 'worker_lease_id','worker_lease_generation','worker_lease_expires_at'] OR + (SELECT count(*) FROM jsonb_object_keys(NEW.paid_slot))<>11 OR + NOT NEW.paid_slot ?& ARRAY[ + 'project_id','assignment_id','submission_id','submission_version', + 'requirement_id','registry_entry_digest','configuration_sha256','profile_id', + 'profile_version','profile_digest','slot_index'] OR + jsonb_typeof(NEW.execution_facts->'submission_version')<>'number' OR + jsonb_typeof(NEW.execution_facts->'evaluation_generation')<>'number' OR + jsonb_typeof(NEW.execution_facts->'worker_lease_generation')<>'number' OR + jsonb_typeof(NEW.paid_slot->'submission_version')<>'number' OR + jsonb_typeof(NEW.paid_slot->'profile_version')<>'number' OR + jsonb_typeof(NEW.paid_slot->'slot_index')<>'number' OR + (NEW.execution_facts->>'submission_version')::integer<=0 OR + (NEW.execution_facts->>'evaluation_generation')::integer<=0 OR + (NEW.execution_facts->>'worker_lease_generation')::integer<=0 OR + (NEW.paid_slot->>'submission_version')::integer<=0 OR + (NEW.paid_slot->>'profile_version')::integer<=0 OR + (NEW.paid_slot->>'slot_index')::integer<>0 OR + NEW.execution_facts->'submission_version' IS DISTINCT FROM + to_jsonb((NEW.execution_facts->>'submission_version')::integer) OR + NEW.execution_facts->'evaluation_generation' IS DISTINCT FROM + to_jsonb((NEW.execution_facts->>'evaluation_generation')::integer) OR + NEW.execution_facts->'worker_lease_generation' IS DISTINCT FROM + to_jsonb((NEW.execution_facts->>'worker_lease_generation')::integer) OR + NEW.paid_slot->'submission_version' IS DISTINCT FROM + to_jsonb((NEW.paid_slot->>'submission_version')::integer) OR + NEW.paid_slot->'profile_version' IS DISTINCT FROM + to_jsonb((NEW.paid_slot->>'profile_version')::integer) OR + NEW.paid_slot->'slot_index' IS DISTINCT FROM + to_jsonb((NEW.paid_slot->>'slot_index')::integer) OR + coalesce(NEW.paid_slot->>'requirement_id','') + !~ '^[a-z][a-z0-9_.-]{0,99}$' OR + coalesce(NEW.paid_slot->>'registry_entry_digest','') + !~ '^sha256:[0-9a-f]{64}$' OR + coalesce(NEW.paid_slot->>'configuration_sha256','') + !~ '^sha256:[0-9a-f]{64}$' OR + coalesce(NEW.paid_slot->>'profile_digest','') + !~ '^sha256:[0-9a-f]{64}$' THEN + RAISE EXCEPTION 'checker model invocation grant facts are invalid' + USING ERRCODE='23514'; + END IF; + + SELECT * INTO authority FROM public.audit_events + WHERE id=NEW.authorization_evidence_id FOR SHARE; + IF authority.id IS NULL OR + authority.event_domain IS DISTINCT FROM 'authority' OR + authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + authority.action_id IS DISTINCT FROM 'checker.model_call.grant' OR + authority.permission_id IS DISTINCT FROM 'checker.model_call.grant' OR + authority.denial_code IS NOT NULL OR + authority.after_facts IS DISTINCT FROM jsonb_build_object( + 'allowed',true, + 'resource_context_digest',authority.after_facts->>'resource_context_digest') OR + coalesce(authority.after_facts->>'resource_context_digest','') + !~ '^sha256:[0-9a-f]{64}$' THEN + RAISE EXCEPTION 'checker model invocation grant authority is invalid' + USING ERRCODE='23514'; + END IF; + + PERFORM 1 FROM public.projects WHERE id=NEW.project_id FOR SHARE; + SELECT project_id INTO task_project FROM public.workstream_tasks + WHERE id=(NEW.execution_facts->>'task_id')::uuid FOR SHARE; + SELECT task_id,project_id INTO assignment_task,assignment_project + FROM public.task_assignments + WHERE id=(NEW.execution_facts->>'assignment_id')::uuid FOR SHARE; + SELECT task_id INTO submission_task FROM public.submissions + WHERE id=(NEW.execution_facts->>'submission_id')::uuid AND + version=(NEW.execution_facts->>'submission_version')::integer FOR SHARE; + IF task_project IS DISTINCT FROM NEW.project_id OR + assignment_task IS DISTINCT FROM (NEW.execution_facts->>'task_id')::uuid OR + assignment_project IS DISTINCT FROM NEW.project_id OR + submission_task IS DISTINCT FROM (NEW.execution_facts->>'task_id')::uuid THEN + RAISE EXCEPTION 'checker model invocation grant task ancestry is invalid' + USING ERRCODE='23514'; + END IF; + + SELECT * INTO run FROM public.checker_runs + WHERE id=NEW.checker_run_id FOR SHARE; + SELECT * INTO registry FROM public.external_checker_registry_entries + WHERE id=NEW.registry_entry_id FOR SHARE; + SELECT * INTO profile FROM public.checker_model_profiles + WHERE id=(NEW.paid_slot->>'profile_id')::uuid AND + profile_version=(NEW.paid_slot->>'profile_version')::integer AND + profile_digest=NEW.paid_slot->>'profile_digest' FOR SHARE; + NEW.issued_at:=clock_timestamp(); + IF NOT isfinite(NEW.expires_at) OR NEW.expires_at<=clock_timestamp() OR + NEW.consumed_at IS NOT NULL OR NEW.selected_request_sha256 IS NOT NULL OR + NEW.selected_attempt_id IS NOT NULL OR + (NEW.execution_facts->>'project_id')::uuid IS DISTINCT FROM NEW.project_id OR + (NEW.paid_slot->>'project_id')::uuid IS DISTINCT FROM NEW.project_id OR + (NEW.execution_facts->>'checker_run_id')::uuid IS DISTINCT FROM NEW.checker_run_id OR + (NEW.execution_facts->>'assignment_id')::uuid IS DISTINCT FROM + (NEW.paid_slot->>'assignment_id')::uuid OR + (NEW.execution_facts->>'submission_id')::uuid IS DISTINCT FROM + (NEW.paid_slot->>'submission_id')::uuid OR + (NEW.execution_facts->>'submission_version')::integer IS DISTINCT FROM + (NEW.paid_slot->>'submission_version')::integer OR + run.id IS NULL OR run.project_id IS DISTINCT FROM NEW.project_id OR + run.task_id IS DISTINCT FROM (NEW.execution_facts->>'task_id')::uuid OR + (run.request_json::jsonb->>'assignment_id')::uuid IS DISTINCT FROM + (NEW.execution_facts->>'assignment_id')::uuid OR + run.submission_id IS DISTINCT FROM (NEW.execution_facts->>'submission_id')::uuid OR + run.submission_version IS DISTINCT FROM + (NEW.execution_facts->>'submission_version')::integer OR + run.evaluation_request_id IS DISTINCT FROM + (NEW.execution_facts->>'evaluation_request_id')::uuid OR + run.evaluation_generation IS DISTINCT FROM + (NEW.execution_facts->>'evaluation_generation')::integer OR + run.worker_lease_id IS DISTINCT FROM + (NEW.execution_facts->>'worker_lease_id')::uuid OR + run.worker_lease_generation IS DISTINCT FROM + (NEW.execution_facts->>'worker_lease_generation')::integer OR + run.worker_lease_expires_at IS DISTINCT FROM + (NEW.execution_facts->>'worker_lease_expires_at')::timestamptz OR + run.worker_lease_expires_at IS DISTINCT FROM NEW.expires_at OR + run.status IS DISTINCT FROM 'running' OR run.phase IS DISTINCT FROM 'post_submission' OR + registry.id IS NULL OR registry.phase IS DISTINCT FROM 'post_submit' OR + registry.entry_digest IS DISTINCT FROM NEW.paid_slot->>'registry_entry_digest' OR + profile.id IS NULL OR profile.project_id IS DISTINCT FROM NEW.project_id OR + profile.registry_entry_id IS DISTINCT FROM NEW.registry_entry_id OR + profile.registry_entry_digest IS DISTINCT FROM + NEW.paid_slot->>'registry_entry_digest' THEN + RAISE EXCEPTION 'checker model invocation grant execution ancestry is invalid' + USING ERRCODE='23514'; + END IF; + + IF run.selected_post_submit_policy_sha256 IS NULL OR + run.selected_post_submit_policy_sha256 IS DISTINCT FROM + run.locked_post_submit_checker_policy_hash OR + jsonb_typeof(run.selected_post_submit_policy_body)<>'object' OR + run.selected_post_submit_policy_body->>'policy_sha256' IS DISTINCT FROM + run.selected_post_submit_policy_sha256 OR + jsonb_typeof(run.selected_post_submit_policy_body->'entries')<>'array' THEN + RAISE EXCEPTION 'checker model invocation grant locked policy is invalid' + USING ERRCODE='23514'; + END IF; + SELECT count(*) INTO binding_matches + FROM jsonb_array_elements(run.selected_post_submit_policy_body->'entries') binding + WHERE binding->>'requirement_id'=NEW.paid_slot->>'requirement_id' AND + binding->>'stage'='post_submit' AND + binding->>'configuration_sha256'=NEW.paid_slot->>'configuration_sha256' AND + binding->'registry'->>'registry_entry_id'=NEW.registry_entry_id::text AND + binding->'registry'->>'entry_digest'=NEW.paid_slot->>'registry_entry_digest' AND + binding->'configuration'->>'model_profile_id'=NEW.paid_slot->>'profile_id' AND + binding->'configuration'->>'model_profile_version'= + NEW.paid_slot->>'profile_version' AND + binding->'configuration'->>'model_profile_digest'=NEW.paid_slot->>'profile_digest'; + IF binding_matches<>1 THEN + RAISE EXCEPTION 'checker model invocation grant binding ancestry is invalid' + USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ + """ + ) + op.execute( + r""" +CREATE TRIGGER checker_model_invocation_grants_guard + BEFORE INSERT OR UPDATE OR DELETE ON public.checker_model_invocation_grants + FOR EACH ROW EXECUTE FUNCTION public.guard_checker_model_invocation_grant() + """ + ) + op.execute( + r""" +CREATE TRIGGER checker_model_invocation_grants_no_truncate + BEFORE TRUNCATE ON public.checker_model_invocation_grants + FOR EACH STATEMENT EXECUTE FUNCTION public.guard_checker_model_no_truncate() + """ + ) + + +def _upgrade_art_custody() -> None: + """Extend verified ART ancestry to call-stable evidence, preserving retained rows.""" + op.execute( + r""" +ALTER TABLE artifact_put_attempts ADD COLUMN model_call_attempt_id uuid REFERENCES checker_model_call_attempts(id) ON DELETE RESTRICT + """ + ) + op.execute( + r""" +CREATE INDEX ix_artifact_put_attempts_model_call_attempt_id ON artifact_put_attempts (model_call_attempt_id) + """ + ) + op.execute( + r""" +ALTER TABLE artifact_operation_receipts ADD COLUMN model_call_attempt_id uuid REFERENCES checker_model_call_attempts(id) ON DELETE RESTRICT + """ + ) + op.execute( + r""" +CREATE INDEX ix_artifact_operation_receipts_model_call_attempt_id ON artifact_operation_receipts (model_call_attempt_id) + """ + ) + op.execute( + r""" +ALTER TABLE artifact_put_attempts DROP CONSTRAINT ck_artifact_put_attempts_producer_reference + """ + ) + op.execute( + r""" +ALTER TABLE artifact_put_attempts ADD CONSTRAINT ck_artifact_put_attempts_producer_reference CHECK ((producer_request_type = 'guide' and task_import_source_id is null and guide_source_item_id is not null and checker_run_id is null and model_call_attempt_id is null and task_id is null and submission_id is null and submission_version is null and logical_role is null) or (producer_request_type = 'checker_output' and task_import_source_id is null and guide_source_item_id is null and ((checker_run_id is not null and model_call_attempt_id is null) or (checker_run_id is null and model_call_attempt_id is not null and logical_role in ('model_request','model_response'))) and task_id is not null and submission_id is not null and submission_version is not null and octet_length(logical_role) between 1 and 100) or (producer_request_type = 'submission_bundle' and task_import_source_id is null and guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null and task_id is not null and submission_id is null and submission_version is null and logical_role is null) or (producer_request_type = 'task_import_source' and task_import_source_id is not null and guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null and task_id is null and submission_id is null and submission_version is null and logical_role is not distinct from 'task_import_source')) + """ + ) + op.execute( + r""" +ALTER TABLE artifact_operation_receipts DROP CONSTRAINT ck_artifact_operation_receipts_contract_producer_reference + """ + ) + op.execute( + r""" +ALTER TABLE artifact_operation_receipts ADD CONSTRAINT ck_artifact_operation_receipts_contract_producer_reference CHECK (contract_version = 2 and put_attempt_id is not null and ((guide_source_item_id is not null and checker_run_id is null and model_call_attempt_id is null and logical_role is null) or (guide_source_item_id is null and checker_run_id is not null and model_call_attempt_id is null and octet_length(logical_role) between 1 and 100) or (guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null and (logical_role is null or logical_role='task_import_source')) or (guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is not null and logical_role in ('model_request','model_response')))) + """ + ) + op.execute( + r""" +ALTER TABLE artifact_bindings DROP CONSTRAINT ck_artifact_bindings_checker_output_lineage + """ + ) + op.execute( + r""" +ALTER TABLE artifact_bindings ADD CONSTRAINT ck_artifact_bindings_checker_output_lineage CHECK ((resource_type in ('checker_run','checker_model_call') and scope_version = 1 and put_attempt_id is not null and verification_receipt_id is not null) or (resource_type not in ('checker_run','checker_model_call') and put_attempt_id is null and verification_receipt_id is null)) + """ + ) + op.execute( + r""" +CREATE OR REPLACE FUNCTION public.guard_checker_output_put_attempt_custody() RETURNS trigger + LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ + BEGIN + IF TG_OP='DELETE' THEN + IF OLD.producer_request_type='checker_output' + OR OLD.checker_output_custody_sealed THEN + RAISE EXCEPTION 'checker output put attempt custody is immutable' + USING ERRCODE='55000'; + END IF; + RETURN OLD; + END IF; + IF OLD.checker_output_custody_sealed THEN + IF NOT NEW.checker_output_custody_sealed + OR (to_jsonb(NEW) - 'checker_output_custody_sealed') + IS DISTINCT FROM + (to_jsonb(OLD) - 'checker_output_custody_sealed') THEN + RAISE EXCEPTION 'checker output put attempt custody is immutable' + USING ERRCODE='55000'; + END IF; + RETURN NEW; + END IF; + IF NEW.checker_output_custody_sealed THEN + IF (to_jsonb(NEW) - 'checker_output_custody_sealed') + IS DISTINCT FROM + (to_jsonb(OLD) - 'checker_output_custody_sealed') + OR NOT EXISTS ( + SELECT 1 FROM artifact_bindings binding + WHERE binding.resource_type IN ('checker_run','checker_model_call') + AND binding.put_attempt_id=OLD.id + ) THEN + RAISE EXCEPTION 'checker output put attempt seal requires binding' + USING ERRCODE='55000'; + END IF; + RETURN NEW; + END IF; + IF OLD.producer_request_type='checker_output' + OR NEW.producer_request_type='checker_output' THEN + IF ROW( + NEW.id, NEW.producer_request_type, NEW.producer_type, NEW.producer_ref, + NEW.project_id, NEW.task_id, NEW.guide_source_item_id, NEW.checker_run_id, NEW.model_call_attempt_id, + NEW.submission_id, NEW.submission_version, NEW.logical_role, + NEW.sha256, NEW.byte_count, NEW.media_type, NEW.storage_namespace_id, + NEW.namespace_fingerprint, NEW.canonical_target, NEW.operation_identity, + NEW.request_digest, NEW.checker_request_digest, NEW.maximum_observations, + NEW.prepared_at, NEW.created_at + ) IS DISTINCT FROM ROW( + OLD.id, OLD.producer_request_type, OLD.producer_type, OLD.producer_ref, + OLD.project_id, OLD.task_id, OLD.guide_source_item_id, OLD.checker_run_id, OLD.model_call_attempt_id, + OLD.submission_id, OLD.submission_version, OLD.logical_role, + OLD.sha256, OLD.byte_count, OLD.media_type, OLD.storage_namespace_id, + OLD.namespace_fingerprint, OLD.canonical_target, OLD.operation_identity, + OLD.request_digest, OLD.checker_request_digest, OLD.maximum_observations, + OLD.prepared_at, OLD.created_at + ) THEN + RAISE EXCEPTION 'checker output put attempt custody is immutable' + USING ERRCODE='55000'; + END IF; + END IF; + RETURN NEW; + END $$ + """ + ) + op.execute( + r""" +CREATE OR REPLACE FUNCTION public.guard_checker_output_verification_job_custody() RETURNS trigger + LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ + BEGIN + IF TG_OP='DELETE' THEN + IF OLD.checker_output_custody_sealed THEN + RAISE EXCEPTION 'checker output verification job custody is immutable' + USING ERRCODE='55000'; + END IF; + RETURN OLD; + END IF; + IF OLD.checker_output_custody_sealed THEN + IF NOT NEW.checker_output_custody_sealed + OR (to_jsonb(NEW) - 'checker_output_custody_sealed') + IS DISTINCT FROM + (to_jsonb(OLD) - 'checker_output_custody_sealed') THEN + RAISE EXCEPTION 'checker output verification job custody is immutable' + USING ERRCODE='55000'; + END IF; + RETURN NEW; + END IF; + IF NEW.checker_output_custody_sealed THEN + IF (to_jsonb(NEW) - 'checker_output_custody_sealed') + IS DISTINCT FROM + (to_jsonb(OLD) - 'checker_output_custody_sealed') + OR NOT EXISTS ( + SELECT 1 + FROM artifact_bindings binding + JOIN artifact_verification_receipts receipt + ON receipt.id=binding.verification_receipt_id + WHERE binding.resource_type IN ('checker_run','checker_model_call') + AND receipt.verification_job_id=OLD.id + ) THEN + RAISE EXCEPTION 'checker output verification job seal requires binding' + USING ERRCODE='55000'; + END IF; + END IF; + RETURN NEW; + END $$ + """ + ) + op.execute( + r""" +CREATE OR REPLACE FUNCTION public.guard_checker_output_replica_custody() RETURNS trigger + LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ + BEGIN + IF TG_OP='DELETE' THEN + IF OLD.checker_output_custody_sealed THEN + RAISE EXCEPTION 'checker output replica custody is immutable' + USING ERRCODE='55000'; + END IF; + RETURN OLD; + END IF; + IF OLD.checker_output_custody_sealed THEN + IF NOT NEW.checker_output_custody_sealed + OR ROW( + NEW.id, NEW.content_id, NEW.storage_namespace_id, + NEW.namespace_fingerprint, NEW.adapter, + NEW.provider_profile, NEW.provider_object_ref + ) IS DISTINCT FROM ROW( + OLD.id, OLD.content_id, OLD.storage_namespace_id, + OLD.namespace_fingerprint, OLD.adapter, + OLD.provider_profile, OLD.provider_object_ref + ) THEN + RAISE EXCEPTION 'checker output replica custody is immutable' + USING ERRCODE='55000'; + END IF; + RETURN NEW; + END IF; + IF NEW.checker_output_custody_sealed THEN + IF (to_jsonb(NEW) - 'checker_output_custody_sealed') + IS DISTINCT FROM + (to_jsonb(OLD) - 'checker_output_custody_sealed') + OR NOT EXISTS ( + SELECT 1 + FROM artifact_bindings binding + JOIN artifact_verification_receipts receipt + ON receipt.id=binding.verification_receipt_id + JOIN artifact_verification_jobs job + ON job.id=receipt.verification_job_id + WHERE binding.resource_type IN ('checker_run','checker_model_call') + AND job.replica_id=OLD.id + ) THEN + RAISE EXCEPTION 'checker output replica seal requires binding' + USING ERRCODE='55000'; + END IF; + END IF; + RETURN NEW; + END $$ + """ + ) + op.execute( + r""" +CREATE OR REPLACE FUNCTION public.guard_checker_output_binding_insert() RETURNS trigger + LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ + DECLARE + verification artifact_verification_receipts%ROWTYPE; + job artifact_verification_jobs%ROWTYPE; + replica artifact_replicas%ROWTYPE; + attempt artifact_put_attempts%ROWTYPE; + content artifact_contents%ROWTYPE; + write_receipt_matches integer; + BEGIN + IF NEW.resource_type NOT IN ('checker_run','checker_model_call') THEN + RETURN NEW; + END IF; + + SELECT receipt.* INTO verification + FROM artifact_verification_receipts receipt + WHERE receipt.id=NEW.verification_receipt_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'checker output binding verified ancestry mismatch' + USING ERRCODE='23514'; + END IF; + + SELECT value.* INTO job + FROM artifact_verification_jobs value + WHERE value.id=verification.verification_job_id + FOR UPDATE; + SELECT value.* INTO replica + FROM artifact_replicas value + WHERE value.id=job.replica_id + FOR UPDATE; + SELECT value.* INTO attempt + FROM artifact_put_attempts value + WHERE value.id=job.originating_put_attempt_id + FOR UPDATE; + SELECT value.* INTO content + FROM artifact_contents value + WHERE value.id=replica.content_id + FOR SHARE; + + SELECT count(*) INTO write_receipt_matches + FROM ( + SELECT receipt.id + FROM artifact_operation_receipts receipt + WHERE attempt.receipt_id IS NOT NULL + AND receipt.id=attempt.receipt_id + AND receipt.put_attempt_id=attempt.id + AND receipt.replica_id=replica.id + AND receipt.checker_run_id IS NOT DISTINCT FROM attempt.checker_run_id + AND receipt.model_call_attempt_id IS NOT DISTINCT FROM attempt.model_call_attempt_id + AND receipt.logical_role=attempt.logical_role + AND receipt.request_digest=attempt.request_digest + AND receipt.provider_object_ref=replica.provider_object_ref + AND receipt.outcome='stored_pending_verification' + UNION ALL + SELECT receipt.id + FROM artifact_put_observation_receipts receipt + WHERE attempt.receipt_id IS NULL + AND receipt.put_attempt_id=attempt.id + AND receipt.execution_generation=attempt.execution_generation + AND receipt.outcome='observed_confirmed' + AND receipt.expected_sha256=attempt.sha256 + AND receipt.observed_sha256=attempt.sha256 + AND receipt.expected_byte_count=attempt.byte_count + AND receipt.observed_byte_count=attempt.byte_count + ) successful_write; + + IF job.id IS NULL OR replica.id IS NULL OR attempt.id IS NULL OR content.id IS NULL + OR NEW.put_attempt_id IS DISTINCT FROM attempt.id + OR NEW.content_id IS DISTINCT FROM content.id + OR NEW.project_id IS DISTINCT FROM attempt.project_id + OR NEW.resource_id IS DISTINCT FROM CASE WHEN NEW.resource_type='checker_model_call' + THEN attempt.model_call_attempt_id::text ELSE attempt.checker_run_id::text END + OR NEW.logical_role IS DISTINCT FROM attempt.logical_role + OR NEW.actor_id IS DISTINCT FROM attempt.producer_ref + OR NEW.attribution_type IS DISTINCT FROM 'service_identity' + OR NEW.scope_version <> 1 + OR NEW.supersedes_binding_id IS NOT NULL + OR attempt.producer_request_type <> 'checker_output' + OR attempt.producer_type <> 'service_identity' + OR attempt.submission_id IS NULL OR attempt.submission_version IS NULL + OR attempt.checker_request_digest IS NULL + OR attempt.status <> 'object_confirmed' + OR attempt.replica_id IS DISTINCT FROM replica.id + OR attempt.storage_namespace_id IS DISTINCT FROM replica.storage_namespace_id + OR attempt.namespace_fingerprint IS DISTINCT FROM replica.namespace_fingerprint + OR attempt.canonical_target IS DISTINCT FROM replica.provider_object_ref + OR attempt.sha256 IS DISTINCT FROM content.sha256 + OR attempt.byte_count IS DISTINCT FROM content.byte_count + OR attempt.media_type IS DISTINCT FROM content.media_type + OR job.originating_put_attempt_id IS DISTINCT FROM attempt.id + OR job.replica_id IS DISTINCT FROM replica.id + OR job.status <> 'verified' + OR job.terminal_result_code IS DISTINCT FROM 'verified' + OR job.terminal_at IS NULL + OR verification.outcome <> 'verified' + OR verification.execution_generation IS DISTINCT FROM job.execution_generation + OR verification.observed_sha256 IS DISTINCT FROM attempt.sha256 + OR verification.observed_byte_count IS DISTINCT FROM attempt.byte_count + OR replica.verification_state <> 'verified' + OR replica.availability_state <> 'available' + OR replica.integrity_state <> 'valid' + OR write_receipt_matches <> 1 THEN + RAISE EXCEPTION 'checker output binding verified ancestry mismatch' + USING ERRCODE='23514'; + END IF; + RETURN NEW; + END $$ + """ + ) + op.execute( + r""" +CREATE OR REPLACE FUNCTION public.seal_checker_output_binding_ancestry() RETURNS trigger + LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ + DECLARE + verification artifact_verification_receipts%ROWTYPE; + job artifact_verification_jobs%ROWTYPE; + BEGIN + IF NEW.resource_type NOT IN ('checker_run','checker_model_call') THEN + RETURN NEW; + END IF; + SELECT receipt.* INTO STRICT verification + FROM artifact_verification_receipts receipt + WHERE receipt.id=NEW.verification_receipt_id; + SELECT value.* INTO STRICT job + FROM artifact_verification_jobs value + WHERE value.id=verification.verification_job_id; + + UPDATE artifact_verification_jobs + SET checker_output_custody_sealed=true + WHERE id=job.id AND NOT checker_output_custody_sealed; + UPDATE artifact_replicas + SET checker_output_custody_sealed=true + WHERE id=job.replica_id AND NOT checker_output_custody_sealed; + UPDATE artifact_put_attempts + SET checker_output_custody_sealed=true + WHERE id=NEW.put_attempt_id AND NOT checker_output_custody_sealed; + RETURN NEW; + END $$ + """ + ) + op.execute( + r""" +CREATE FUNCTION public.guard_checker_model_artifact_ancestry() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE effect checker_model_call_attempts%ROWTYPE; + slot checker_model_call_slots%ROWTYPE; + profile checker_model_profiles%ROWTYPE; +BEGIN + IF NEW.model_call_attempt_id IS NULL THEN RETURN NEW; END IF; + SELECT * INTO STRICT effect FROM checker_model_call_attempts WHERE id=NEW.model_call_attempt_id; + SELECT * INTO STRICT slot FROM checker_model_call_slots WHERE id=effect.slot_id; + SELECT * INTO STRICT profile FROM checker_model_profiles + WHERE id=slot.profile_id AND profile_version=slot.profile_version + AND profile_digest=slot.profile_digest; + IF ROW(NEW.project_id,NEW.task_id,NEW.submission_id,NEW.submission_version) + IS DISTINCT FROM ROW(slot.project_id,slot.task_id,slot.submission_id,slot.submission_version) + OR NEW.checker_run_id IS NOT NULL + OR NEW.media_type IS DISTINCT FROM 'application/json' + OR NEW.byte_count < 1 + OR (NEW.logical_role='model_request' AND + (NEW.sha256 IS DISTINCT FROM effect.request_sha256 + OR NEW.byte_count > profile.maximum_request_bytes)) + OR (NEW.logical_role='model_response' AND + (effect.state NOT IN ('dispatching','uncertain','completed','known_failed') + OR NEW.byte_count > profile.maximum_response_bytes + OR (effect.response_content_sha256 IS NOT NULL + AND NEW.sha256 IS DISTINCT FROM effect.response_content_sha256))) + OR NEW.logical_role NOT IN ('model_request','model_response') THEN + RAISE EXCEPTION 'model artifact call ancestry mismatch' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ + """ + ) + op.execute( + r""" +CREATE TRIGGER checker_model_artifact_ancestry BEFORE INSERT ON artifact_put_attempts +FOR EACH ROW EXECUTE FUNCTION public.guard_checker_model_artifact_ancestry() + """ + ) + + +def _upgrade_authorization_vocabulary() -> None: + """Admit exactly the new model actions in the retained audit evidence constraint.""" + op.execute("LOCK TABLE public.audit_events IN ACCESS EXCLUSIVE MODE") + name = "ck_audit_events_authorization_action_evidence" + definition = op.get_bind().execute(text( + "SELECT pg_catalog.pg_get_constraintdef(oid) FROM pg_catalog.pg_constraint " + "WHERE conrelid='public.audit_events'::regclass AND conname=:name" + ), {"name": name}).scalar_one() + anchor = ( + "(((action_id)::text = 'contribution.compensation_unit.create'::text) AND " + "((permission_id)::text = 'compensation.policy.manage'::text))" + ) + actions = (('checker.model_call.prepare', 'checker.model_call.prepare'), ('checker.model_call.dispatch', 'checker.model_call.dispatch'), ('checker.model_call.recover', 'checker.model_call.recover'), ('checker.model_call.observe', 'checker.model_call.observe'), ('checker.model_call.complete', 'checker.model_call.complete'), ('checker.model_call.grant', 'checker.model_call.grant'), ('project.model_profile.provision', 'project.model_profile.manage'), ('project.model_budget.configure', 'project.model_budget.manage'), ('project.model_call.recovery_child.authorize', 'project.model_call.recovery_child.authorize')) + if definition.count(anchor) != 2 or any(action in definition for action, _ in actions): + raise RuntimeError("model-call audit action constraint shape changed") + addition = "".join( + f" OR (((action_id)::text = '{action}'::text) AND " + f"((permission_id)::text = '{permission}'::text))" + for action, permission in actions + ) + op.execute("ALTER TABLE public.audit_events DROP CONSTRAINT " + name) + op.execute("ALTER TABLE public.audit_events ADD CONSTRAINT " + name + " " + + definition.replace(anchor, anchor + addition)) + + +def downgrade() -> None: + """Refuse removal of retained paid-call and budget evidence.""" + raise RuntimeError("Workstream v0.1 migrations cannot be downgraded; recreate the database") + + +_TABLES = ( + r""" +CREATE TABLE public.checker_model_profiles ( + id uuid NOT NULL, + project_id uuid NOT NULL, + profile_version integer NOT NULL, + profile_digest varchar(71) NOT NULL, + registry_entry_id uuid NOT NULL, + registry_entry_digest varchar(71) NOT NULL, + provider varchar(160) NOT NULL, + api varchar(160) NOT NULL, + model_snapshot varchar(160) NOT NULL, + generation_settings jsonb NOT NULL, + maximum_request_bytes integer NOT NULL, + maximum_output_bytes integer NOT NULL, + maximum_response_bytes integer NOT NULL, + maximum_input_tokens integer NOT NULL, + maximum_output_tokens integer NOT NULL, + deadline_ms integer NOT NULL, + call_slots integer NOT NULL, + pricing_policy_id uuid NOT NULL, + pricing_policy_version integer NOT NULL, + pricing_policy_digest varchar(71) NOT NULL, + uncached_input_rate bigint NOT NULL, + cached_input_rate bigint NOT NULL, + output_rate bigint NOT NULL, + operation_id uuid NOT NULL, + actor_profile_id uuid NOT NULL, + authorization_evidence_id uuid NOT NULL, + created_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_checker_model_profiles PRIMARY KEY(id), + CONSTRAINT ck_checker_model_profiles_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_checker_model_profiles_digests CHECK ( + profile_digest ~ '^sha256:[0-9a-f]{64}$' AND + registry_entry_digest ~ '^sha256:[0-9a-f]{64}$' AND + pricing_policy_digest ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT ck_checker_model_profiles_identifiers CHECK ( + provider='openai_responses' AND api='responses.v1' AND + model_snapshot ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$'), + CONSTRAINT ck_checker_model_profiles_settings CHECK ( + generation_settings = jsonb_build_object( + 'reasoning_effort',generation_settings->>'reasoning_effort', + 'text_verbosity',generation_settings->>'text_verbosity') AND + generation_settings->>'reasoning_effort' IN + ('none','minimal','low','medium','high','xhigh','max') AND + generation_settings->>'text_verbosity' IN ('low','medium','high')), + CONSTRAINT ck_checker_model_profiles_bounds CHECK ( + profile_version>0 AND pricing_policy_version>0 AND call_slots=1 AND + maximum_request_bytes BETWEEN 1 AND 1048576 AND + maximum_output_bytes BETWEEN 1 AND 1048576 AND + maximum_response_bytes BETWEEN 1 AND 1048576 AND + maximum_response_bytes >= 6 * maximum_output_bytes + 4096 AND + maximum_input_tokens BETWEEN 1 AND 10000000 AND + maximum_output_tokens BETWEEN 1 AND 10000000 AND + deadline_ms BETWEEN 100 AND 3600000 AND + uncached_input_rate>=0 AND cached_input_rate>=0 AND output_rate>=0 AND + greatest(uncached_input_rate,cached_input_rate,output_rate)>0 AND + maximum_input_tokens::numeric * + greatest(uncached_input_rate,cached_input_rate)::numeric + + maximum_output_tokens::numeric * output_rate::numeric BETWEEN 1 AND 9000000000000000000), + CONSTRAINT uq_checker_model_profiles_version UNIQUE(project_id,profile_version), + CONSTRAINT uq_checker_model_profiles_identity UNIQUE(id,profile_version,profile_digest), + CONSTRAINT uq_checker_model_profiles_digest UNIQUE(profile_digest), + CONSTRAINT uq_checker_model_profiles_operation UNIQUE(operation_id), + CONSTRAINT uq_checker_model_profiles_authority UNIQUE(authorization_evidence_id), + CONSTRAINT fk_checker_model_profiles_project FOREIGN KEY(project_id) + REFERENCES public.projects(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_profiles_registry FOREIGN KEY(registry_entry_id) + REFERENCES public.external_checker_registry_entries(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_profiles_actor FOREIGN KEY(actor_profile_id) + REFERENCES public.actor_profiles(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_profiles_authority FOREIGN KEY(authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT +) +""", + r""" +CREATE TABLE public.checker_model_budget_configurations ( + id uuid NOT NULL, + project_id uuid NOT NULL, + configuration_version integer NOT NULL, + configuration_digest varchar(71) NOT NULL, + lifetime_limit_nanodollars bigint NOT NULL, + operation_id uuid NOT NULL, + actor_profile_id uuid NOT NULL, + authorization_evidence_id uuid NOT NULL, + created_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_checker_model_budget_configurations PRIMARY KEY(id), + CONSTRAINT ck_checker_model_budget_configurations_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_checker_model_budget_configurations_values CHECK ( + configuration_version>0 AND lifetime_limit_nanodollars>0 AND + configuration_digest ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT uq_checker_model_budget_configurations_version + UNIQUE(project_id,configuration_version), + CONSTRAINT uq_checker_model_budget_configurations_identity + UNIQUE(id,project_id,configuration_version,configuration_digest), + CONSTRAINT uq_checker_model_budget_configurations_attempt_identity + UNIQUE(id,configuration_version,configuration_digest), + CONSTRAINT uq_checker_model_budget_configurations_id_project UNIQUE(id,project_id), + CONSTRAINT uq_checker_model_budget_configurations_digest UNIQUE(configuration_digest), + CONSTRAINT uq_checker_model_budget_configurations_operation UNIQUE(operation_id), + CONSTRAINT uq_checker_model_budget_configurations_authority + UNIQUE(authorization_evidence_id), + CONSTRAINT fk_checker_model_budget_configurations_project FOREIGN KEY(project_id) + REFERENCES public.projects(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_budget_configurations_actor FOREIGN KEY(actor_profile_id) + REFERENCES public.actor_profiles(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_budget_configurations_authority + FOREIGN KEY(authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT +) +""", + r""" +CREATE TABLE public.checker_model_budget_accounts ( + project_id uuid NOT NULL, + current_configuration_id uuid NOT NULL, + lifetime_reserved_nanodollars bigint DEFAULT 0 NOT NULL, + lifetime_settled_nanodollars bigint DEFAULT 0 NOT NULL, + CONSTRAINT pk_checker_model_budget_accounts PRIMARY KEY(project_id), + CONSTRAINT uq_checker_model_budget_accounts_configuration + UNIQUE(current_configuration_id), + CONSTRAINT ck_checker_model_budget_accounts_values CHECK ( + lifetime_reserved_nanodollars>=0 AND lifetime_settled_nanodollars>=0 AND + lifetime_reserved_nanodollars::numeric+lifetime_settled_nanodollars::numeric + <=9000000000000000000), + CONSTRAINT fk_checker_model_budget_accounts_project FOREIGN KEY(project_id) + REFERENCES public.projects(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_budget_accounts_configuration + FOREIGN KEY(current_configuration_id,project_id) + REFERENCES public.checker_model_budget_configurations(id,project_id) + ON DELETE RESTRICT +) +""", + r""" +CREATE TABLE public.checker_model_call_slots ( + id uuid NOT NULL, + project_id uuid NOT NULL, + task_id uuid NOT NULL, + assignment_id uuid NOT NULL, + submission_id uuid NOT NULL, + submission_version integer NOT NULL, + requirement_id varchar(100) NOT NULL, + registry_entry_digest varchar(71) NOT NULL, + configuration_sha256 varchar(71) NOT NULL, + profile_id uuid NOT NULL, + profile_version integer NOT NULL, + profile_digest varchar(71) NOT NULL, + slot_index integer NOT NULL, + first_request_sha256 varchar(71) NOT NULL, + created_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_checker_model_call_slots PRIMARY KEY(id), + CONSTRAINT ck_checker_model_call_slots_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_checker_model_call_slots_values CHECK ( + submission_version>0 AND profile_version>0 AND slot_index=0 AND + requirement_id ~ '^[a-z][a-z0-9_.-]{0,99}$' AND + registry_entry_digest ~ '^sha256:[0-9a-f]{64}$' AND + configuration_sha256 ~ '^sha256:[0-9a-f]{64}$' AND + profile_digest ~ '^sha256:[0-9a-f]{64}$' AND + first_request_sha256 ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT uq_checker_model_call_slots_stable_identity UNIQUE( + project_id,assignment_id,submission_id,submission_version,requirement_id, + registry_entry_digest,configuration_sha256,profile_id,profile_version, + profile_digest,slot_index), + CONSTRAINT fk_checker_model_call_slots_project FOREIGN KEY(project_id) + REFERENCES public.projects(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_slots_task_project FOREIGN KEY(task_id,project_id) + REFERENCES public.workstream_tasks(id,project_id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_slots_assignment FOREIGN KEY(assignment_id) + REFERENCES public.task_assignments(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_slots_submission FOREIGN KEY( + submission_id,task_id,submission_version) + REFERENCES public.submissions(id,task_id,version) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_slots_profile FOREIGN KEY( + profile_id,profile_version,profile_digest) + REFERENCES public.checker_model_profiles(id,profile_version,profile_digest) + ON DELETE RESTRICT +) +""", + r""" +CREATE TABLE public.checker_model_call_attempts ( + id uuid NOT NULL, + slot_id uuid NOT NULL, + attempt_number integer NOT NULL, + internal_call_request_id uuid NOT NULL, + parent_attempt_id uuid, + request_sha256 varchar(71) NOT NULL, + state varchar(32) NOT NULL, + prepared_authorization_evidence_id uuid NOT NULL, + request_binding_id uuid, + request_content_id uuid, + request_put_attempt_id uuid, + request_verification_receipt_id uuid, + request_content_sha256 varchar(71), + request_byte_count integer, + budget_configuration_id uuid, + budget_configuration_version integer, + budget_configuration_digest varchar(71), + reserved_nanodollars bigint, + dispatch_authorization_evidence_id uuid, + provider_response_id varchar(256), + response_binding_id uuid, + response_content_id uuid, + response_put_attempt_id uuid, + response_verification_receipt_id uuid, + response_content_sha256 varchar(71), + response_byte_count integer, + input_tokens integer, + cached_input_tokens integer, + output_tokens integer, + reasoning_tokens integer, + settled_nanodollars bigint, + settlement_authorization_evidence_id uuid, + settled_at timestamptz, + no_effect_proof_id uuid, + no_effect_proof_digest varchar(71), + no_effect_observed_at timestamptz, + terminal_authorization_evidence_id uuid, + uncertainty_digest varchar(71), + observation_authorization_evidence_id uuid, + prepared_at timestamptz DEFAULT clock_timestamp() NOT NULL, + dispatched_at timestamptz, + terminal_at timestamptz, + CONSTRAINT pk_checker_model_call_attempts PRIMARY KEY(id), + CONSTRAINT ck_checker_model_call_attempts_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_checker_model_call_attempts_internal_id_uuid7 CHECK ( + (get_byte(uuid_send(internal_call_request_id),6)>>4)=7 AND + (get_byte(uuid_send(internal_call_request_id),8)&192)=128), + CONSTRAINT ck_checker_model_call_attempts_values CHECK ( + attempt_number>0 AND request_sha256 ~ '^sha256:[0-9a-f]{64}$' AND + state IN ('prepared','dispatching','uncertain','completed','known_failed','known_no_effect') AND + (provider_response_id IS NULL OR + provider_response_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$') AND + (reserved_nanodollars IS NULL OR reserved_nanodollars>0) AND + (settled_nanodollars IS NULL OR settled_nanodollars>=0)), + CONSTRAINT uq_checker_model_call_attempts_number UNIQUE(slot_id,attempt_number), + CONSTRAINT uq_checker_model_call_attempts_internal_request UNIQUE(internal_call_request_id), + CONSTRAINT uq_checker_model_call_attempts_prepare_authority + UNIQUE(prepared_authorization_evidence_id), + CONSTRAINT uq_checker_model_call_attempts_dispatch_authority + UNIQUE(dispatch_authorization_evidence_id), + CONSTRAINT uq_checker_model_call_attempts_observation_authority + UNIQUE(observation_authorization_evidence_id), + CONSTRAINT uq_checker_model_call_attempts_terminal_authority + UNIQUE(terminal_authorization_evidence_id), + CONSTRAINT uq_checker_model_call_attempts_settlement_authority + UNIQUE(settlement_authorization_evidence_id), + CONSTRAINT fk_checker_model_call_attempts_slot FOREIGN KEY(slot_id) + REFERENCES public.checker_model_call_slots(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_attempts_parent FOREIGN KEY(parent_attempt_id) + REFERENCES public.checker_model_call_attempts(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_attempts_budget FOREIGN KEY( + budget_configuration_id,budget_configuration_version,budget_configuration_digest) + REFERENCES public.checker_model_budget_configurations( + id,configuration_version,configuration_digest) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_attempts_prepare_authority + FOREIGN KEY(prepared_authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_attempts_dispatch_authority + FOREIGN KEY(dispatch_authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_attempts_observation_authority + FOREIGN KEY(observation_authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_attempts_terminal_authority + FOREIGN KEY(terminal_authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_attempts_settlement_authority + FOREIGN KEY(settlement_authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_attempts_request_binding FOREIGN KEY(request_binding_id) + REFERENCES public.artifact_bindings(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_attempts_request_content FOREIGN KEY(request_content_id) + REFERENCES public.artifact_contents(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_attempts_request_put FOREIGN KEY(request_put_attempt_id) + REFERENCES public.artifact_put_attempts(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_attempts_request_verification + FOREIGN KEY(request_verification_receipt_id) + REFERENCES public.artifact_verification_receipts(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_attempts_response_binding FOREIGN KEY(response_binding_id) + REFERENCES public.artifact_bindings(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_attempts_response_content FOREIGN KEY(response_content_id) + REFERENCES public.artifact_contents(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_attempts_response_put FOREIGN KEY(response_put_attempt_id) + REFERENCES public.artifact_put_attempts(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_attempts_response_verification + FOREIGN KEY(response_verification_receipt_id) + REFERENCES public.artifact_verification_receipts(id) ON DELETE RESTRICT +) +""", + r""" +CREATE TABLE public.checker_model_call_recoveries ( + id uuid NOT NULL, + attempt_id uuid NOT NULL, + checker_run_id uuid NOT NULL, + worker_lease_id uuid NOT NULL, + worker_lease_generation integer NOT NULL, + authorization_evidence_id uuid NOT NULL, + created_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_checker_model_call_recoveries PRIMARY KEY(id), + CONSTRAINT ck_checker_model_call_recoveries_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_checker_model_call_recoveries_generation CHECK(worker_lease_generation>0), + CONSTRAINT uq_checker_model_call_recoveries_attachment UNIQUE( + attempt_id,checker_run_id,worker_lease_id,worker_lease_generation), + CONSTRAINT uq_checker_model_call_recoveries_authority UNIQUE(authorization_evidence_id), + CONSTRAINT fk_checker_model_call_recoveries_attempt FOREIGN KEY(attempt_id) + REFERENCES public.checker_model_call_attempts(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_recoveries_run FOREIGN KEY(checker_run_id) + REFERENCES public.checker_runs(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_call_recoveries_authority FOREIGN KEY(authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT +) +""", +) + + +_FUNCTIONS = ( + r""" +CREATE FUNCTION public.checker_model_canonical_json(value jsonb) RETURNS text +LANGUAGE plpgsql IMMUTABLE STRICT SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE encoded text; +BEGIN + CASE pg_catalog.jsonb_typeof(value) + WHEN 'object' THEN + SELECT '{'||coalesce(pg_catalog.string_agg( + pg_catalog.to_json(item_key)::text||':'||public.checker_model_canonical_json(item_value), + ',' ORDER BY item_key COLLATE "C"),'')||'}' INTO encoded + FROM pg_catalog.jsonb_each(value) AS items(item_key,item_value); + RETURN encoded; + WHEN 'array' THEN + SELECT '['||coalesce(pg_catalog.string_agg( + public.checker_model_canonical_json(item_value),',' ORDER BY item_order),'')||']' + INTO encoded FROM pg_catalog.jsonb_array_elements(value) WITH ORDINALITY + AS items(item_value,item_order); + RETURN encoded; + ELSE RETURN value::text; + END CASE; +END $$ +""", + r""" +CREATE FUNCTION public.checker_model_json_digest(value jsonb) RETURNS varchar +LANGUAGE sql IMMUTABLE STRICT SET search_path=pg_catalog,public,pg_temp AS $$ + SELECT 'sha256:'||pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to( + public.checker_model_canonical_json(value),'UTF8')),'hex') +$$ +""", + r""" +CREATE FUNCTION public.checker_model_profile_authority_digest( + r public.checker_model_profiles +) RETURNS varchar LANGUAGE sql IMMUTABLE STRICT +SET search_path=pg_catalog,public,pg_temp AS $$ + WITH facts(value) AS (SELECT pg_catalog.jsonb_build_object( + 'operation_id',r.operation_id,'actor_profile_id',r.actor_profile_id, + 'spec',pg_catalog.jsonb_build_object( + 'project_id',r.project_id,'profile_version',r.profile_version, + 'registry_entry_id',r.registry_entry_id, + 'registry_entry_digest',r.registry_entry_digest,'provider',r.provider, + 'api',r.api,'model_snapshot',r.model_snapshot,'settings',r.generation_settings, + 'maximum_request_bytes',r.maximum_request_bytes, + 'maximum_output_bytes',r.maximum_output_bytes, + 'maximum_response_bytes',r.maximum_response_bytes, + 'maximum_input_tokens',r.maximum_input_tokens, + 'maximum_output_tokens',r.maximum_output_tokens,'deadline_ms',r.deadline_ms, + 'call_slots',r.call_slots,'pricing',pg_catalog.jsonb_build_object( + 'pricing_policy_id',r.pricing_policy_id, + 'pricing_policy_version',r.pricing_policy_version, + 'pricing_policy_digest',r.pricing_policy_digest, + 'uncached_input_nanodollars_per_token',r.uncached_input_rate, + 'cached_input_nanodollars_per_token',r.cached_input_rate, + 'output_nanodollars_per_token',r.output_rate)), + 'profile_digest',r.profile_digest)) + SELECT public.checker_model_json_digest(pg_catalog.jsonb_build_object( + 'resource_context',pg_catalog.jsonb_build_object( + 'resource_type','project_model_administration','resource_id',r.operation_id, + 'project_id',r.project_id,'actor_profile_id',r.actor_profile_id, + 'action_id','project.model_profile.provision','facts',facts.value, + 'facts_digest',public.checker_model_json_digest(pg_catalog.jsonb_build_object( + 'action','project.model_profile.provision','facts',facts.value))))) FROM facts +$$ +""", + r""" +CREATE FUNCTION public.checker_model_budget_authority_digest( + r public.checker_model_budget_configurations +) RETURNS varchar LANGUAGE sql IMMUTABLE STRICT +SET search_path=pg_catalog,public,pg_temp AS $$ + WITH facts(value) AS (SELECT pg_catalog.jsonb_build_object( + 'operation_id',r.operation_id,'actor_profile_id',r.actor_profile_id, + 'spec',pg_catalog.jsonb_build_object('project_id',r.project_id, + 'configuration_version',r.configuration_version, + 'lifetime_limit_nanodollars',r.lifetime_limit_nanodollars), + 'configuration_digest',r.configuration_digest)) + SELECT public.checker_model_json_digest(pg_catalog.jsonb_build_object( + 'resource_context',pg_catalog.jsonb_build_object( + 'resource_type','project_model_administration','resource_id',r.operation_id, + 'project_id',r.project_id,'actor_profile_id',r.actor_profile_id, + 'action_id','project.model_budget.configure','facts',facts.value, + 'facts_digest',public.checker_model_json_digest(pg_catalog.jsonb_build_object( + 'action','project.model_budget.configure','facts',facts.value))))) FROM facts +$$ +""", + r""" +CREATE FUNCTION public.guard_checker_model_profile() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE expected_version integer; +DECLARE authority public.audit_events%ROWTYPE; +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'checker model profiles are immutable' USING ERRCODE='55000'; + END IF; + SELECT * INTO authority FROM public.audit_events + WHERE id=NEW.authorization_evidence_id FOR SHARE; + IF authority.id IS NULL OR authority.event_domain<>'authority' OR + authority.event_type<>'SensitiveAuthorizationAllowed' OR + authority.actor_ref_kind<>'actor_profile' OR + authority.actor_id<>NEW.actor_profile_id::text OR + authority.action_id<>'project.model_profile.provision' OR + authority.permission_id<>'project.model_profile.manage' OR + authority.denial_code IS NOT NULL OR + authority.after_facts<>pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest', + public.checker_model_profile_authority_digest(NEW)) THEN + RAISE EXCEPTION 'checker model profile authority is invalid' USING ERRCODE='23514'; + END IF; + PERFORM pg_advisory_xact_lock(hashtextextended('checker-model-profile:'||NEW.project_id,0)); + SELECT coalesce(max(profile_version),0)+1 INTO expected_version + FROM public.checker_model_profiles WHERE project_id=NEW.project_id; + IF NEW.profile_version<>expected_version THEN + RAISE EXCEPTION 'checker model profile version is not sequential' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.guard_checker_model_budget_configuration() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE account public.checker_model_budget_accounts%ROWTYPE; +DECLARE current_version integer; +DECLARE authority public.audit_events%ROWTYPE; +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'checker model budget configurations are immutable' USING ERRCODE='55000'; + END IF; + SELECT * INTO authority FROM public.audit_events + WHERE id=NEW.authorization_evidence_id FOR SHARE; + IF authority.id IS NULL OR authority.event_domain<>'authority' OR + authority.event_type<>'SensitiveAuthorizationAllowed' OR + authority.actor_ref_kind<>'actor_profile' OR + authority.actor_id<>NEW.actor_profile_id::text OR + authority.action_id<>'project.model_budget.configure' OR + authority.permission_id<>'project.model_budget.manage' OR + authority.denial_code IS NOT NULL OR + authority.after_facts<>pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest', + public.checker_model_budget_authority_digest(NEW)) THEN + RAISE EXCEPTION 'checker model budget authority is invalid' USING ERRCODE='23514'; + END IF; + PERFORM pg_advisory_xact_lock(hashtextextended('checker-model-budget:'||NEW.project_id,0)); + SELECT * INTO account FROM public.checker_model_budget_accounts + WHERE project_id=NEW.project_id FOR UPDATE; + IF account.project_id IS NULL THEN + IF NEW.configuration_version<>1 THEN + RAISE EXCEPTION 'initial checker model budget version must be one' USING ERRCODE='23514'; + END IF; + ELSE + SELECT configuration_version INTO current_version + FROM public.checker_model_budget_configurations + WHERE id=account.current_configuration_id; + IF NEW.configuration_version<>current_version+1 OR + NEW.lifetime_limit_nanodollars< + account.lifetime_reserved_nanodollars+account.lifetime_settled_nanodollars THEN + RAISE EXCEPTION 'invalid checker model budget adjustment' USING ERRCODE='23514'; + END IF; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.guard_checker_model_budget_account() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE chosen public.checker_model_budget_configurations%ROWTYPE; +DECLARE prior public.checker_model_budget_configurations%ROWTYPE; +BEGIN + IF TG_OP='DELETE' THEN + RAISE EXCEPTION 'checker model budget accounts cannot be deleted' USING ERRCODE='55000'; + END IF; + SELECT * INTO chosen FROM public.checker_model_budget_configurations + WHERE id=NEW.current_configuration_id AND project_id=NEW.project_id FOR SHARE; + IF chosen.id IS NULL OR + NEW.lifetime_reserved_nanodollars+NEW.lifetime_settled_nanodollars> + chosen.lifetime_limit_nanodollars THEN + RAISE EXCEPTION 'checker model budget ceiling exceeded' USING ERRCODE='23514'; + END IF; + IF TG_OP='INSERT' THEN + IF chosen.configuration_version<>1 OR NEW.lifetime_reserved_nanodollars<>0 OR + NEW.lifetime_settled_nanodollars<>0 THEN + RAISE EXCEPTION 'invalid initial checker model budget account' USING ERRCODE='23514'; + END IF; + RETURN NEW; + END IF; + IF NEW.project_id<>OLD.project_id THEN + RAISE EXCEPTION 'checker model budget project is immutable' USING ERRCODE='55000'; + END IF; + IF NEW.current_configuration_id<>OLD.current_configuration_id THEN + SELECT * INTO prior FROM public.checker_model_budget_configurations + WHERE id=OLD.current_configuration_id FOR SHARE; + IF ROW(NEW.lifetime_reserved_nanodollars,NEW.lifetime_settled_nanodollars) + IS DISTINCT FROM + ROW(OLD.lifetime_reserved_nanodollars,OLD.lifetime_settled_nanodollars) OR + chosen.configuration_version<>prior.configuration_version+1 THEN + RAISE EXCEPTION 'invalid checker model budget pointer transition' USING ERRCODE='23514'; + END IF; + ELSIF NOT ( + (NEW.lifetime_reserved_nanodollars>OLD.lifetime_reserved_nanodollars AND + NEW.lifetime_settled_nanodollars=OLD.lifetime_settled_nanodollars) OR + (NEW.lifetime_reserved_nanodollars=OLD.lifetime_settled_nanodollars AND + NEW.lifetime_settled_nanodollars-OLD.lifetime_settled_nanodollars<= + OLD.lifetime_reserved_nanodollars-NEW.lifetime_reserved_nanodollars) + ) THEN + RAISE EXCEPTION 'invalid checker model budget counter transition' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.guard_checker_model_call_slot() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE assignment_task uuid; +DECLARE assignment_project uuid; +DECLARE profile public.checker_model_profiles%ROWTYPE; +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'checker model call slots are immutable' USING ERRCODE='55000'; + END IF; + SELECT task_id,project_id INTO assignment_task,assignment_project + FROM public.task_assignments WHERE id=NEW.assignment_id FOR SHARE; + SELECT * INTO profile FROM public.checker_model_profiles WHERE + id=NEW.profile_id AND profile_version=NEW.profile_version AND + profile_digest=NEW.profile_digest FOR SHARE; + IF assignment_task IS DISTINCT FROM NEW.task_id OR + assignment_project IS DISTINCT FROM NEW.project_id OR profile.id IS NULL OR + profile.project_id<>NEW.project_id OR + profile.registry_entry_digest<>NEW.registry_entry_digest THEN + RAISE EXCEPTION 'invalid checker model call slot ownership' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.guard_checker_model_call_attempt() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE slot public.checker_model_call_slots%ROWTYPE; +DECLARE profile public.checker_model_profiles%ROWTYPE; +DECLARE budget public.checker_model_budget_configurations%ROWTYPE; +DECLARE account public.checker_model_budget_accounts%ROWTYPE; +DECLARE parent public.checker_model_call_attempts%ROWTYPE; +DECLARE maximum_reservation numeric; +DECLARE exact_cost numeric; +DECLARE request_complete boolean; +DECLARE response_complete boolean; +DECLARE usage_complete boolean; +BEGIN + IF TG_OP='DELETE' THEN + RAISE EXCEPTION 'checker model call attempts cannot be deleted' USING ERRCODE='55000'; + END IF; + SELECT * INTO slot FROM public.checker_model_call_slots WHERE id=NEW.slot_id FOR SHARE; + SELECT * INTO profile FROM public.checker_model_profiles WHERE id=slot.profile_id FOR SHARE; + IF slot.id IS NULL OR profile.id IS NULL OR NEW.request_sha256<>slot.first_request_sha256 THEN + RAISE EXCEPTION 'invalid checker model call attempt ownership' USING ERRCODE='23514'; + END IF; + maximum_reservation := profile.maximum_input_tokens::numeric * + greatest(profile.uncached_input_rate,profile.cached_input_rate)::numeric + + profile.maximum_output_tokens::numeric * profile.output_rate::numeric; + request_complete := NEW.request_binding_id IS NOT NULL AND NEW.request_content_id IS NOT NULL AND + NEW.request_put_attempt_id IS NOT NULL AND NEW.request_verification_receipt_id IS NOT NULL AND + NEW.request_content_sha256=NEW.request_sha256 AND + NEW.request_byte_count BETWEEN 1 AND profile.maximum_request_bytes; + response_complete := NEW.response_binding_id IS NOT NULL AND NEW.response_content_id IS NOT NULL AND + NEW.response_put_attempt_id IS NOT NULL AND NEW.response_verification_receipt_id IS NOT NULL AND + NEW.response_content_sha256 ~ '^sha256:[0-9a-f]{64}$' AND + NEW.response_byte_count BETWEEN 1 AND profile.maximum_response_bytes; + usage_complete := NEW.input_tokens IS NOT NULL AND NEW.cached_input_tokens IS NOT NULL AND + NEW.output_tokens IS NOT NULL AND NEW.reasoning_tokens IS NOT NULL AND + NEW.input_tokens BETWEEN 0 AND profile.maximum_input_tokens AND + NEW.cached_input_tokens BETWEEN 0 AND NEW.input_tokens AND + NEW.output_tokens BETWEEN 0 AND profile.maximum_output_tokens AND + NEW.reasoning_tokens BETWEEN 0 AND NEW.output_tokens; + IF usage_complete THEN + exact_cost := (NEW.input_tokens-NEW.cached_input_tokens)::numeric * + profile.uncached_input_rate::numeric + NEW.cached_input_tokens::numeric * + profile.cached_input_rate::numeric + NEW.output_tokens::numeric * + profile.output_rate::numeric; + END IF; + IF TG_OP='INSERT' THEN + IF NEW.attempt_number=1 THEN + IF NEW.parent_attempt_id IS NOT NULL THEN + RAISE EXCEPTION 'initial checker model attempt cannot have a parent' USING ERRCODE='23514'; + END IF; + ELSE + SELECT * INTO parent FROM public.checker_model_call_attempts + WHERE id=NEW.parent_attempt_id FOR SHARE; + IF parent.id IS NULL OR parent.slot_id<>NEW.slot_id OR + parent.attempt_number+1<>NEW.attempt_number OR + parent.attempt_number<>(SELECT max(attempt_number) + FROM public.checker_model_call_attempts WHERE slot_id=NEW.slot_id) OR + NOT (parent.state='known_no_effect' OR + (parent.state='known_failed' AND parent.settled_nanodollars IS NOT NULL AND + parent.settlement_authorization_evidence_id IS NOT NULL)) THEN + RAISE EXCEPTION 'invalid checker model child attempt' USING ERRCODE='23514'; + END IF; + END IF; + ELSIF ROW(NEW.slot_id,NEW.attempt_number,NEW.internal_call_request_id, + NEW.parent_attempt_id,NEW.request_sha256, + NEW.prepared_authorization_evidence_id,NEW.prepared_at) + IS DISTINCT FROM + ROW(OLD.slot_id,OLD.attempt_number,OLD.internal_call_request_id, + OLD.parent_attempt_id,OLD.request_sha256, + OLD.prepared_authorization_evidence_id,OLD.prepared_at) THEN + RAISE EXCEPTION 'checker model call attempt identity is immutable' USING ERRCODE='55000'; + END IF; + IF TG_OP='UPDATE' AND OLD.state<>'prepared' AND + ROW(NEW.request_binding_id,NEW.request_content_id,NEW.request_put_attempt_id, + NEW.request_verification_receipt_id,NEW.request_content_sha256, + NEW.request_byte_count,NEW.budget_configuration_id, + NEW.budget_configuration_version,NEW.budget_configuration_digest, + NEW.reserved_nanodollars,NEW.dispatch_authorization_evidence_id, + NEW.dispatched_at) + IS DISTINCT FROM + ROW(OLD.request_binding_id,OLD.request_content_id,OLD.request_put_attempt_id, + OLD.request_verification_receipt_id,OLD.request_content_sha256, + OLD.request_byte_count,OLD.budget_configuration_id, + OLD.budget_configuration_version,OLD.budget_configuration_digest, + OLD.reserved_nanodollars,OLD.dispatch_authorization_evidence_id, + OLD.dispatched_at) THEN + RAISE EXCEPTION 'checker model dispatch facts are immutable' USING ERRCODE='55000'; + END IF; + IF NEW.state='prepared' THEN + IF TG_OP='UPDATE' OR NEW.request_binding_id IS NOT NULL OR + NEW.request_content_id IS NOT NULL OR NEW.request_put_attempt_id IS NOT NULL OR + NEW.request_verification_receipt_id IS NOT NULL OR + NEW.request_content_sha256 IS NOT NULL OR NEW.request_byte_count IS NOT NULL OR + NEW.budget_configuration_id IS NOT NULL OR + NEW.budget_configuration_version IS NOT NULL OR + NEW.budget_configuration_digest IS NOT NULL OR + NEW.reserved_nanodollars IS NOT NULL OR + NEW.dispatch_authorization_evidence_id IS NOT NULL OR + NEW.dispatched_at IS NOT NULL OR NEW.provider_response_id IS NOT NULL OR + NEW.response_binding_id IS NOT NULL OR NEW.response_content_id IS NOT NULL OR + NEW.response_put_attempt_id IS NOT NULL OR + NEW.response_verification_receipt_id IS NOT NULL OR + NEW.response_content_sha256 IS NOT NULL OR NEW.response_byte_count IS NOT NULL OR + NEW.input_tokens IS NOT NULL OR NEW.cached_input_tokens IS NOT NULL OR + NEW.output_tokens IS NOT NULL OR NEW.reasoning_tokens IS NOT NULL OR + NEW.settled_nanodollars IS NOT NULL OR + NEW.settlement_authorization_evidence_id IS NOT NULL OR NEW.settled_at IS NOT NULL OR + NEW.no_effect_proof_id IS NOT NULL OR NEW.no_effect_proof_digest IS NOT NULL OR + NEW.no_effect_observed_at IS NOT NULL OR + NEW.terminal_authorization_evidence_id IS NOT NULL OR + NEW.uncertainty_digest IS NOT NULL OR + NEW.observation_authorization_evidence_id IS NOT NULL OR NEW.terminal_at IS NOT NULL THEN + RAISE EXCEPTION 'invalid prepared checker model call' USING ERRCODE='23514'; + END IF; + ELSE + SELECT * INTO budget FROM public.checker_model_budget_configurations WHERE + id=NEW.budget_configuration_id AND + configuration_version=NEW.budget_configuration_version AND + configuration_digest=NEW.budget_configuration_digest FOR SHARE; + SELECT * INTO account FROM public.checker_model_budget_accounts + WHERE project_id=slot.project_id FOR SHARE; + IF NOT request_complete OR budget.id IS NULL OR budget.project_id<>slot.project_id OR + (TG_OP='UPDATE' AND OLD.state='prepared' AND + account.current_configuration_id IS DISTINCT FROM budget.id) OR + NEW.reserved_nanodollars IS NULL OR + NEW.reserved_nanodollars::numeric<>maximum_reservation OR + NEW.dispatch_authorization_evidence_id IS NULL OR NEW.dispatched_at IS NULL THEN + RAISE EXCEPTION 'invalid dispatched checker model call' USING ERRCODE='23514'; + END IF; + END IF; + IF NEW.state='dispatching' THEN + IF NEW.provider_response_id IS NOT NULL OR NEW.uncertainty_digest IS NOT NULL OR + NEW.observation_authorization_evidence_id IS NOT NULL OR + NEW.response_binding_id IS NOT NULL OR NEW.response_content_id IS NOT NULL OR + NEW.response_put_attempt_id IS NOT NULL OR + NEW.response_verification_receipt_id IS NOT NULL OR + NEW.response_content_sha256 IS NOT NULL OR NEW.response_byte_count IS NOT NULL OR + NEW.input_tokens IS NOT NULL OR NEW.cached_input_tokens IS NOT NULL OR + NEW.output_tokens IS NOT NULL OR NEW.reasoning_tokens IS NOT NULL OR + NEW.settled_nanodollars IS NOT NULL OR + NEW.settlement_authorization_evidence_id IS NOT NULL OR NEW.settled_at IS NOT NULL OR + NEW.no_effect_proof_id IS NOT NULL OR NEW.no_effect_proof_digest IS NOT NULL OR + NEW.no_effect_observed_at IS NOT NULL OR + NEW.terminal_authorization_evidence_id IS NOT NULL OR NEW.terminal_at IS NOT NULL THEN + RAISE EXCEPTION 'invalid dispatching checker model call' USING ERRCODE='23514'; + END IF; + ELSIF NEW.state='uncertain' THEN + IF NEW.uncertainty_digest !~ '^sha256:[0-9a-f]{64}$' OR + NEW.observation_authorization_evidence_id IS NULL OR + NEW.response_binding_id IS NOT NULL OR NEW.response_content_id IS NOT NULL OR + NEW.response_put_attempt_id IS NOT NULL OR + NEW.response_verification_receipt_id IS NOT NULL OR + NEW.response_content_sha256 IS NOT NULL OR NEW.response_byte_count IS NOT NULL OR + NEW.input_tokens IS NOT NULL OR NEW.cached_input_tokens IS NOT NULL OR + NEW.output_tokens IS NOT NULL OR NEW.reasoning_tokens IS NOT NULL OR + NEW.settled_nanodollars IS NOT NULL OR + NEW.settlement_authorization_evidence_id IS NOT NULL OR NEW.settled_at IS NOT NULL OR + NEW.no_effect_proof_id IS NOT NULL OR NEW.no_effect_proof_digest IS NOT NULL OR + NEW.no_effect_observed_at IS NOT NULL OR + NEW.terminal_authorization_evidence_id IS NOT NULL OR NEW.terminal_at IS NOT NULL THEN + RAISE EXCEPTION 'invalid uncertain checker model call' USING ERRCODE='23514'; + END IF; + ELSIF NEW.state='completed' THEN + IF NEW.provider_response_id IS NULL OR NOT response_complete OR NOT usage_complete OR + NEW.settled_nanodollars IS NULL OR NEW.settled_nanodollars::numeric<>exact_cost OR + NEW.terminal_authorization_evidence_id IS NULL OR NEW.terminal_at IS NULL OR + NEW.no_effect_proof_id IS NOT NULL OR NEW.no_effect_proof_digest IS NOT NULL OR + NEW.no_effect_observed_at IS NOT NULL OR + NEW.settlement_authorization_evidence_id IS NOT NULL OR NEW.settled_at IS NOT NULL THEN + RAISE EXCEPTION 'invalid completed checker model call' USING ERRCODE='23514'; + END IF; + ELSIF NEW.state='known_failed' THEN + IF NEW.provider_response_id IS NULL OR NOT response_complete OR + NEW.terminal_authorization_evidence_id IS NULL OR NEW.terminal_at IS NULL OR + NEW.no_effect_proof_id IS NOT NULL OR + NOT usage_complete OR NEW.settled_nanodollars IS NULL OR + NEW.settled_nanodollars::numeric<>exact_cost OR + NEW.settlement_authorization_evidence_id IS NULL OR NEW.settled_at IS NULL THEN + RAISE EXCEPTION 'invalid known-failed checker model call' USING ERRCODE='23514'; + END IF; + ELSIF NEW.state='known_no_effect' THEN + IF NEW.provider_response_id IS NOT NULL OR NEW.response_binding_id IS NOT NULL OR + NEW.response_content_id IS NOT NULL OR NEW.response_put_attempt_id IS NOT NULL OR + NEW.response_verification_receipt_id IS NOT NULL OR + NEW.response_content_sha256 IS NOT NULL OR NEW.response_byte_count IS NOT NULL OR + NEW.input_tokens IS NOT NULL OR NEW.cached_input_tokens IS NOT NULL OR + NEW.output_tokens IS NOT NULL OR NEW.reasoning_tokens IS NOT NULL OR + NEW.settled_nanodollars IS NOT NULL OR + NEW.settlement_authorization_evidence_id IS NOT NULL OR NEW.settled_at IS NOT NULL OR + NEW.no_effect_proof_id IS NULL OR + NEW.no_effect_proof_digest !~ '^sha256:[0-9a-f]{64}$' OR + NEW.no_effect_observed_at IS NULL OR + NEW.terminal_authorization_evidence_id IS NULL OR NEW.terminal_at IS NULL THEN + RAISE EXCEPTION 'invalid no-effect checker model call' USING ERRCODE='23514'; + END IF; + END IF; + IF TG_OP='UPDATE' AND OLD.state='uncertain' AND + (NEW.uncertainty_digest IS DISTINCT FROM OLD.uncertainty_digest OR + NEW.observation_authorization_evidence_id IS DISTINCT FROM + OLD.observation_authorization_evidence_id OR + (OLD.provider_response_id IS NOT NULL AND + NEW.provider_response_id IS DISTINCT FROM OLD.provider_response_id)) THEN + RAISE EXCEPTION 'checker model uncertainty facts are immutable' USING ERRCODE='55000'; + END IF; + IF TG_OP='UPDATE' AND OLD.state='known_failed' AND + ROW(NEW.provider_response_id,NEW.response_binding_id,NEW.response_content_id, + NEW.response_put_attempt_id,NEW.response_verification_receipt_id, + NEW.response_content_sha256,NEW.response_byte_count, + NEW.no_effect_proof_id,NEW.no_effect_proof_digest,NEW.no_effect_observed_at, + NEW.terminal_authorization_evidence_id,NEW.terminal_at, + NEW.uncertainty_digest,NEW.observation_authorization_evidence_id) + IS DISTINCT FROM + ROW(OLD.provider_response_id,OLD.response_binding_id,OLD.response_content_id, + OLD.response_put_attempt_id,OLD.response_verification_receipt_id, + OLD.response_content_sha256,OLD.response_byte_count, + OLD.no_effect_proof_id,OLD.no_effect_proof_digest,OLD.no_effect_observed_at, + OLD.terminal_authorization_evidence_id,OLD.terminal_at, + OLD.uncertainty_digest,OLD.observation_authorization_evidence_id) THEN + RAISE EXCEPTION 'checker model terminal facts are immutable' USING ERRCODE='55000'; + END IF; + IF TG_OP='UPDATE' AND NOT ( + (OLD.state='prepared' AND NEW.state='dispatching') OR + (OLD.state='dispatching' AND NEW.state IN + ('uncertain','completed','known_failed','known_no_effect')) OR + (OLD.state='uncertain' AND NEW.state IN ('completed','known_failed','known_no_effect')) + ) THEN + RAISE EXCEPTION 'invalid checker model call state transition' USING ERRCODE='55000'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.guard_checker_model_recovery() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'checker model recovery attachments are immutable' USING ERRCODE='55000'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.reconcile_checker_model_budget() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE target_project uuid; +DECLARE account public.checker_model_budget_accounts%ROWTYPE; +DECLARE calculated_reserved numeric; +DECLARE calculated_settled numeric; +BEGIN + IF TG_TABLE_NAME='checker_model_budget_accounts' THEN + target_project:=coalesce(NEW.project_id,OLD.project_id); + ELSE + SELECT project_id INTO target_project FROM public.checker_model_call_slots + WHERE id=coalesce(NEW.slot_id,OLD.slot_id); + END IF; + SELECT * INTO account FROM public.checker_model_budget_accounts + WHERE project_id=target_project; + SELECT coalesce(sum(CASE WHEN + a.state IN ('dispatching','uncertain') + THEN a.reserved_nanodollars ELSE 0 END),0), + coalesce(sum(CASE WHEN a.state='completed' OR + (a.state='known_failed' AND a.settled_nanodollars IS NOT NULL) + THEN a.settled_nanodollars ELSE 0 END),0) + INTO calculated_reserved,calculated_settled + FROM public.checker_model_call_attempts a + JOIN public.checker_model_call_slots s ON s.id=a.slot_id + WHERE s.project_id=target_project; + IF account.project_id IS NULL OR + account.lifetime_reserved_nanodollars::numeric<>calculated_reserved OR + account.lifetime_settled_nanodollars::numeric<>calculated_settled THEN + RAISE EXCEPTION 'checker model budget conservation failed' USING ERRCODE='23514'; + END IF; + RETURN NULL; +END $$ +""", + r""" +CREATE FUNCTION public.guard_checker_model_no_truncate() RETURNS trigger +LANGUAGE plpgsql AS $$ BEGIN + RAISE EXCEPTION 'checker model ledger cannot be truncated' USING ERRCODE='55000'; +END $$ +""", +) + + +_TRIGGERS = ( + "CREATE TRIGGER checker_model_profiles_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.checker_model_profiles FOR EACH ROW " + "EXECUTE FUNCTION public.guard_checker_model_profile()", + "CREATE TRIGGER checker_model_budget_configurations_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.checker_model_budget_configurations FOR EACH ROW " + "EXECUTE FUNCTION public.guard_checker_model_budget_configuration()", + "CREATE TRIGGER checker_model_budget_accounts_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.checker_model_budget_accounts FOR EACH ROW " + "EXECUTE FUNCTION public.guard_checker_model_budget_account()", + "CREATE TRIGGER checker_model_call_slots_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.checker_model_call_slots FOR EACH ROW " + "EXECUTE FUNCTION public.guard_checker_model_call_slot()", + "CREATE TRIGGER checker_model_call_attempts_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.checker_model_call_attempts FOR EACH ROW " + "EXECUTE FUNCTION public.guard_checker_model_call_attempt()", + "CREATE TRIGGER checker_model_call_recoveries_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.checker_model_call_recoveries FOR EACH ROW " + "EXECUTE FUNCTION public.guard_checker_model_recovery()", + "CREATE CONSTRAINT TRIGGER checker_model_budget_account_reconcile " + "AFTER INSERT OR UPDATE OR DELETE ON public.checker_model_budget_accounts " + "DEFERRABLE INITIALLY DEFERRED FOR EACH ROW " + "EXECUTE FUNCTION public.reconcile_checker_model_budget()", + "CREATE CONSTRAINT TRIGGER checker_model_call_attempt_reconcile " + "AFTER INSERT OR UPDATE OR DELETE ON public.checker_model_call_attempts " + "DEFERRABLE INITIALLY DEFERRED FOR EACH ROW " + "EXECUTE FUNCTION public.reconcile_checker_model_budget()", + *( + "CREATE TRIGGER " + + table + + "_no_truncate BEFORE TRUNCATE ON public." + + table + + " FOR EACH STATEMENT EXECUTE FUNCTION public.guard_checker_model_no_truncate()" + for table in ( + "checker_model_profiles", + "checker_model_budget_configurations", + "checker_model_budget_accounts", + "checker_model_call_slots", + "checker_model_call_attempts", + "checker_model_call_recoveries", + ) + ), +) diff --git a/backend/app/adapters/artifacts/__init__.py b/backend/app/adapters/artifacts/__init__.py index 20c4be342..844ef0aed 100644 --- a/backend/app/adapters/artifacts/__init__.py +++ b/backend/app/adapters/artifacts/__init__.py @@ -626,22 +626,39 @@ def worker_post_submission_materialization(*, sessions) -> PostSubmissionMateria def checker_output_storage( *, sessions, store, namespace, preparation, settings ) -> CheckerArtifactOutputPort: - """Compose hidden output custody; live producer and write authority remain absent.""" - from app.modules.artifacts.checker_outputs import CheckerArtifactOutputService, DenyCheckerOutputWriteAuthority - from app.modules.artifacts.schemas import DenyArtifactInternalAuthority + """Compose private call evidence through canonical admission and verification.""" + from app.adapters.auth import checker_output_write_authorization + from app.modules.artifacts.checker_outputs import CheckerArtifactOutputService + from uuid import uuid4 + + def authority(session, identity): + request_id = uuid4() + return PreparedArtifactInternalAuthority( + session, service_identity=identity, request_id=request_id, correlation_id=request_id, + ) + return CheckerArtifactOutputService( sessions=sessions, store=store, namespace=namespace, preparation=preparation, settings=settings, reservations=checker_output_reservations, - authority=lambda session: DenyCheckerOutputWriteAuthority(), - internal_authority=lambda session: DenyArtifactInternalAuthority(), + authority=checker_output_write_authorization, + internal_authority=lambda session: authority(session, ServiceIdentity.ARTIFACT_PUT_RESOLVER), + verification_authority=lambda session: authority(session, ServiceIdentity.ARTIFACT_VERIFIER), ) def checker_output_binding(session, *, namespace) -> CheckerOutputBindingPort: - """Compose a deny-only caller-transaction binding participant.""" - from app.modules.artifacts.checker_output_bindings import CheckerOutputBindingService, DenyCheckerOutputBindingAuthority + """Bind one exact private call effect to independently verified ART bytes.""" + from app.adapters.auth import checker_output_binding_authorization + from app.modules.artifacts.checker_output_bindings import CheckerOutputBindingService return CheckerOutputBindingService(session, namespace_fingerprint=namespace.namespace_fingerprint, - reservations=checker_output_reservations(session), authority=DenyCheckerOutputBindingAuthority()) + reservations=checker_output_reservations(session), authority=checker_output_binding_authorization(session)) + + +def checker_model_evidence(session): + """Expose verified call binding facts to CHECKERS without transcript bytes.""" + from app.modules.artifacts.checker_model_evidence import CheckerModelEvidence + + return CheckerModelEvidence(session) async def get_task_import_source_commands( diff --git a/backend/app/adapters/auth/__init__.py b/backend/app/adapters/auth/__init__.py index b294bbf4a..d678ad5be 100644 --- a/backend/app/adapters/auth/__init__.py +++ b/backend/app/adapters/auth/__init__.py @@ -44,7 +44,7 @@ from app.modules.authorization.kernel import AuthorizationService from app.modules.authorization.prepared import PreparedAuthorizationService from app.modules.authorization.repository import AdminAuthorizationRepository -from app.modules.authorization.runtime import AuthorizationContext +from app.modules.authorization.runtime import AuthorizationContext, ServiceAuthorizationContext from app.modules.authorization.task_authorization import PreparedTaskAuthorization from app.modules.authorization.task_import_sources import PreparedTaskImportSourceAuthorization from app.modules.authorization.guide_compilation_projections import ( @@ -114,6 +114,47 @@ def checker_registry_authorization( return ExternalCheckerRegistryAuthorizationAdapter(kernel, prepared) +def checker_model_authorization(session: AsyncSession, context: AuthorizationContext): + """Compose exact project-model authority using the existing AUTH/PREP graph.""" + from app.modules.authorization.checker_model_authorization import CheckerModelAuthorization + + repository = AdminAuthorizationRepository(session) + revalidate = None + if isinstance(context, ServiceAuthorizationContext): + from app.modules.authorization.prepared import fixed_service_context_revalidator + + revalidate = fixed_service_context_revalidator(repository, ServiceIdentity.CHECKER_POST_SUBMIT) + kernel = AuthorizationService( + session, context, admin_repository=repository, revalidate_service=revalidate, + ) + prepared = PreparedAuthorizationService(session, context, kernel, repository) + return CheckerModelAuthorization(kernel, prepared) + + +async def checker_model_service_authorization(session: AsyncSession, request_id: UUID): + """Resolve the fixed POST checker principal within the caller's transaction.""" + from app.modules.authorization.prepared import fixed_service_authorization_context + + context = await fixed_service_authorization_context( + session, ServiceIdentity.CHECKER_POST_SUBMIT, request_id, request_id, + ) + return checker_model_authorization(session, context) + + +def checker_output_write_authorization(session: AsyncSession): + """Compose private model-evidence admission and recovery authority.""" + from app.modules.authorization.checker_output_authorization import PreparedCheckerOutputWriteAuthorization + + return PreparedCheckerOutputWriteAuthorization(session) + + +def checker_output_binding_authorization(session: AsyncSession): + """Compose verified call-evidence publication authority.""" + from app.modules.authorization.checker_output_authorization import PreparedCheckerOutputBindingAuthorization + + return PreparedCheckerOutputBindingAuthorization(session) + + def guide_sufficiency_projection_authorization( session: AsyncSession, ) -> GuideSufficiencyProjectionAuthorization: diff --git a/backend/app/adapters/checkers/__init__.py b/backend/app/adapters/checkers/__init__.py index e8305b080..d0e52b706 100644 --- a/backend/app/adapters/checkers/__init__.py +++ b/backend/app/adapters/checkers/__init__.py @@ -9,6 +9,7 @@ ExternalCheckerExecutionAdapter, ExternalCheckerExecutionResponse, ExternalCheckerMaterialGrant, + ExternalCheckerModelGrant, ExternalCheckerServiceHealth, ) from app.interfaces.external_services import ( @@ -106,9 +107,11 @@ async def execute( self, request: ExternalCheckerExecutionRequest, grant: ExternalCheckerMaterialGrant, + *, + model_grant: ExternalCheckerModelGrant | None = None, ) -> ExternalCheckerExecutionResponse: """Resolve and invoke the sole provider for one selected binding.""" - return await self._resolve().execute(request, grant) + return await self._resolve().execute(request, grant, model_grant=model_grant) async def health(self) -> ExternalCheckerServiceHealth: """Resolve the sole provider before checking runtime readiness.""" @@ -120,6 +123,15 @@ def configured_external_checker_execution(settings) -> ExternalCheckerExecutionA return _ConfiguredExternalCheckerExecution(settings) +def configured_post_submit_external_execution(settings, *, sessions) -> ExternalCheckerExecutionAdapter: + """Add durable project model custody to the existing Rust image transport.""" + from app.adapters.checkers.model_execution import ModelAwareExternalExecution + + return ModelAwareExternalExecution( + execution=configured_external_checker_execution(settings), sessions=sessions, + ) + + def selected_pre_submit_checker_execution(*, settings, archive_inspector): """Compose default/PRE checking with deferred project-image transport.""" return SelectedPreSubmitCheckerExecutionAdapter( @@ -135,6 +147,13 @@ def external_checker_registry_reader(session): return ExternalCheckerRegistryReader(session) +def model_profile_reader(session): + """Resolve exact immutable project model profiles without mutation authority.""" + from app.modules.checkers.model_calls.profiles import ModelProfileReader + + return ModelProfileReader(session) + + def external_checker_registry_service(session, authority): """Compose CHECKERS registration and readback with request-scoped AUTH.""" from app.modules.checkers.external_registry import ExternalCheckerRegistryService @@ -232,9 +251,7 @@ def build(self, request: _ExecutionRequest) -> SelectedPreSubmitProcessor: or attempt_request_digest is None or grants is None ): - raise SelectedPreSubmitInfrastructureUnavailable( - "material_unavailable" - ) + raise SelectedPreSubmitInfrastructureUnavailable("material_unavailable") identity = PreSubmitExternalCheckerIdentity( project_id=request.project_id, task_id=request.task_id, @@ -285,7 +302,8 @@ def checker_retry_service(session, context, *, cursor_secret=None): """Compose supported Operator recovery from existing owner operations.""" from app.adapters.audit import checker_retry_audit from app.adapters.auth import ( - checker_retry_authorization, checker_retry_discovery_authorization, + checker_retry_authorization, + checker_retry_discovery_authorization, ) from app.adapters.outbox import outbox_append from app.adapters.tasks import evaluation_task_guard @@ -294,18 +312,27 @@ def checker_retry_service(session, context, *, cursor_secret=None): tasks = evaluation_task_guard(session) return CheckerRetryService( - session, actor_profile_id=context.actor_profile_id, - tasks=tasks, evaluations=EvaluationCoordinator(session, tasks=tasks), + session, + actor_profile_id=context.actor_profile_id, + tasks=tasks, + evaluations=EvaluationCoordinator(session, tasks=tasks), authorization=checker_retry_authorization(session, context), discovery=checker_retry_discovery_authorization( - session, context, cursor_secret, + session, + context, + cursor_secret, ), - events=outbox_append(session), audit=checker_retry_audit(session), + events=outbox_append(session), + audit=checker_retry_audit(session), ) def post_submit_delivery_service( - session, sessions, context, *, cursor_secret=None, + session, + sessions, + context, + *, + cursor_secret=None, ): """Compose exact Operator inspection and one-child reconciliation.""" from app.adapters.auth import ( @@ -328,9 +355,7 @@ async def detached_classify(delivery): return await classify_post_submit_delivery( delivery, evaluations=evaluations, - requests=evaluation_delivery_request_reader( - source_session, evaluations - ), + requests=evaluation_delivery_request_reader(source_session, evaluations), outcomes=post_submit_delivery_outcome(source_session, sessions), ) @@ -340,16 +365,18 @@ async def detached_classify(delivery): deliveries=finalized_unknown_deliveries(session), events=outbox_append(session), evaluations=evaluation_coordinator(session), - requests=evaluation_delivery_request_reader( - session, evaluation_coordinator(session) - ), + requests=evaluation_delivery_request_reader(session, evaluation_coordinator(session)), outcomes=post_submit_delivery_outcome(session, sessions), read_authority=post_submit_delivery_read_authorization( - session, context, cursor_secret, + session, + context, + cursor_secret, ), authorization=post_submit_delivery_authorization(session, context), detached_classify=detached_classify, ) + + def post_submission_executor(*, sessions, materialization): """Compose the sole executor with exact fixed-service AUTH at both phases.""" from app.adapters.outbox import outbox_append @@ -369,10 +396,47 @@ def post_submission_executor(*, sessions, materialization): def checker_output_reservations(session): - """Resolve exact active run/lease custody for artifact output reservations.""" - from app.modules.checkers.execution_coordination import CheckerOutputReservations + """Resolve exact call custody with TASK-before-CHECKERS request locking.""" + from app.adapters.tasks import evaluation_task_guard + from app.modules.checkers.model_calls.custody import ( + CheckerOutputReservations, + ModelCallCurrentExecution, + ) + + tasks = evaluation_task_guard(session) + return CheckerOutputReservations( + session, + current=ModelCallCurrentExecution(session, tasks=tasks), + tasks=tasks, + ) + + +def model_call_service(session, authority): + """Compose durable model spending with exact TASK and ART owner ports.""" + from app.adapters.tasks import evaluation_task_guard + from app.adapters.artifacts import checker_model_evidence + from app.modules.checkers.model_calls.custody import ModelCallCurrentExecution + from app.modules.checkers.model_calls.service import ModelCallService - return CheckerOutputReservations(session) + return ModelCallService( + session, + authority=authority, + current_execution=ModelCallCurrentExecution(session, tasks=evaluation_task_guard(session)), + evidence=checker_model_evidence(session), + ) + + +def model_invocation_grants(session, authority): + """Compose short-lived image capabilities without creating provider effects.""" + from app.adapters.tasks import evaluation_task_guard + from app.modules.checkers.model_calls.custody import ModelCallCurrentExecution + from app.modules.checkers.model_calls.grants import ModelInvocationGrants + + return ModelInvocationGrants( + session, + authority=authority, + current=ModelCallCurrentExecution(session, tasks=evaluation_task_guard(session)), + ) def current_post_submit_execution(session): @@ -527,7 +591,7 @@ def executor(child, original, payload, request): original=original, payload=payload, request=request, - selected_execution=configured_external_checker_execution(get_settings()), + selected_execution=configured_post_submit_external_execution(get_settings(), sessions=sessions), ) return PostSubmitDeliveryReconciliationHandler( diff --git a/backend/app/adapters/checkers/external_service.py b/backend/app/adapters/checkers/external_service.py index f1f402bc7..90c24d828 100644 --- a/backend/app/adapters/checkers/external_service.py +++ b/backend/app/adapters/checkers/external_service.py @@ -13,6 +13,7 @@ ExternalCheckerExecutionResponse, ExternalCheckerIsolationReceipt, ExternalCheckerMaterialGrant, + ExternalCheckerModelGrant, ExternalCheckerServiceHealth, ) from app.interfaces.external_services import ( @@ -63,12 +64,20 @@ async def execute( self, request: ExternalCheckerExecutionRequest, grant: ExternalCheckerMaterialGrant, + *, + model_grant: ExternalCheckerModelGrant | None = None, ) -> ExternalCheckerExecutionResponse: """Exchange one bounded request and revalidate every returned fact.""" try: checked = ExternalCheckerExecutionRequest.model_validate(request) if type(grant) is not ExternalCheckerMaterialGrant: raise ValueError("external checker material grant is invalid") + if model_grant is not None and ( + type(model_grant) is not ExternalCheckerModelGrant + or checked.identity.phase != "post_submit" + or model_grant.binding_digest != checked.request_digest + ): + raise ValueError("external checker model grant is invalid") payload = json.dumps( { "protocol_version": _PROTOCOL, @@ -78,6 +87,14 @@ async def execute( "grant_id": grant.grant_id, "binding_digest": grant.binding_digest, }, + "model_grant": ( + None + if model_grant is None + else { + "grant_id": model_grant.grant_id, + "binding_digest": model_grant.binding_digest, + } + ), }, ensure_ascii=False, allow_nan=False, diff --git a/backend/app/adapters/checkers/model_execution.py b/backend/app/adapters/checkers/model_execution.py new file mode 100644 index 000000000..223ad8385 --- /dev/null +++ b/backend/app/adapters/checkers/model_execution.py @@ -0,0 +1,98 @@ +"""Compose optional project-image model callbacks into the sole POST execution path.""" + +from app.interfaces.external_services import ExternalServiceUnavailableError +from app.modules.checkers.api.model_calls import ( + ModelCallExecutionFacts, + ModelCallUnavailable, + ModelPaidSlotKey, +) +from app.modules.checkers.api.model_profiles import configured_model_profile_selector + + +def model_execution_facts(request) -> ModelCallExecutionFacts: + """Translate the existing typed POST identity without inventing new lineage.""" + identity = request.identity + if identity.phase != "post_submit": + raise ModelCallUnavailable("model_call_execution_unavailable") + return ModelCallExecutionFacts( + project_id=identity.project_id, + task_id=identity.task_id, + assignment_id=identity.assignment_id, + submission_id=identity.submission_id, + submission_version=identity.submission_version, + evaluation_request_id=identity.evaluation_request_id, + evaluation_generation=identity.evaluation_generation, + checker_run_id=identity.attempt_id, + worker_lease_id=identity.lease_id, + worker_lease_generation=identity.lease_generation, + worker_lease_expires_at=identity.lease_expires_at, + ) + + +class ModelAwareExternalExecution: + """Grant one private model callback and verify its completion before a verdict.""" + + def __init__(self, *, execution, sessions): + self._execution, self._sessions = execution, sessions + + @property + def identity(self): + """Keep the same fixed Rust provider identity.""" + return self._execution.identity + + async def health(self): + """Use the existing external execution health contract.""" + return await self._execution.health() + + async def execute(self, request, grant, *, model_grant=None): + """Ordinary images run directly; model images must complete their exact call.""" + from app.adapters.auth import checker_model_service_authorization + from app.adapters.checkers import model_call_service, model_invocation_grants + + if model_grant is not None: + raise ExternalServiceUnavailableError(self.identity) + selector = configured_model_profile_selector( + request.configuration, + stage=request.identity.phase, + ) + if selector is None: + return await self._execution.execute(request, grant) + facts = model_execution_facts(request) + slot = ModelPaidSlotKey( + project_id=facts.project_id, + assignment_id=facts.assignment_id, + submission_id=facts.submission_id, + submission_version=facts.submission_version, + requirement_id=request.input.get("requirement_id"), + registry_entry_digest=request.registry.entry_digest, + configuration_sha256=request.configuration_sha256, + profile_id=selector.profile_id, + profile_version=selector.profile_version, + profile_digest=selector.profile_digest, + ) + try: + async with self._sessions() as session, session.begin(): + authority = await checker_model_service_authorization( + session, facts.evaluation_request_id + ) + callback = await model_invocation_grants(session, authority).issue( + facts, + slot, + request.request_digest, + registry_entry_id=request.registry.registry_entry_id, + ) + response = await self._execution.execute(request, grant, model_grant=callback) + if response.result.outcome == "completed": + async with self._sessions() as session, session.begin(): + authority = await checker_model_service_authorization( + session, facts.evaluation_request_id + ) + effect = await model_invocation_grants(session, authority).completed_effect( + callback.grant_id, + request.request_digest, + facts, + ) + await model_call_service(session, authority).attach_completed(facts, effect) + return response + except ModelCallUnavailable as error: + raise ExternalServiceUnavailableError(self.identity) from error diff --git a/backend/app/adapters/checkers/model_provider.py b/backend/app/adapters/checkers/model_provider.py new file mode 100644 index 000000000..eb4222455 --- /dev/null +++ b/backend/app/adapters/checkers/model_provider.py @@ -0,0 +1,300 @@ +"""OpenAI Responses transport for policy-bound checker model calls.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import math +import re +from typing import Any, Literal, Protocol + +from app.interfaces.checker_model_provider import ( + CHECKER_MODEL_CAPABILITY_KEY, + MAX_CHECKER_MODEL_NAME_LENGTH, + MAX_CHECKER_MODEL_OUTPUT_BYTES, + MAX_CHECKER_MODEL_PROMPT_BYTES, + MAX_CHECKER_MODEL_TOKEN_COUNT, + CheckerModelCreateFailure, + CheckerModelCreateOutcome, + CheckerModelCreateResult, + CheckerModelObservation, + CheckerModelProvider, + CheckerModelProviderStatus, + CheckerModelRecoveryOutcome, + CheckerModelRecoveryResult, + CheckerModelRequest, + CheckerModelResponseReference, + CheckerModelUsage, +) +from app.interfaces.external_services import ( + ExternalServiceAdapterFactory, + ExternalServiceAdapterIdentity, +) + + +OPENAI_RESPONSES_PROVIDER_KEY = "openai_responses" +_IDENTITY = ExternalServiceAdapterIdentity( + CHECKER_MODEL_CAPABILITY_KEY, + OPENAI_RESPONSES_PROVIDER_KEY, +) + +ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"] +TextVerbosity = Literal["low", "medium", "high"] +_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh", "max"} +_TEXT_VERBOSITIES = {"low", "medium", "high"} +_MODEL_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") + + +@dataclass(frozen=True, slots=True) +class OpenAIResponsesSettings: + """Trusted profile settings that an image cannot select or override.""" + + api_key: str = field(repr=False, compare=False) + model: str + maximum_request_bytes: int + maximum_input_tokens: int + maximum_output_tokens: int + maximum_output_bytes: int + request_timeout_seconds: float + reasoning_effort: ReasoningEffort + text_verbosity: TextVerbosity + + def __post_init__(self) -> None: + if ( + not isinstance(self.api_key, str) + or not self.api_key + or len(self.api_key) > 8_192 + or not isinstance(self.model, str) + or not self.model + or len(self.model) > MAX_CHECKER_MODEL_NAME_LENGTH + or _MODEL_IDENTIFIER.fullmatch(self.model) is None + or type(self.maximum_request_bytes) is not int + or not 1 <= self.maximum_request_bytes <= MAX_CHECKER_MODEL_PROMPT_BYTES + or type(self.maximum_input_tokens) is not int + or not 1 <= self.maximum_input_tokens <= MAX_CHECKER_MODEL_TOKEN_COUNT + or type(self.maximum_output_tokens) is not int + or not 1 <= self.maximum_output_tokens <= MAX_CHECKER_MODEL_TOKEN_COUNT + or type(self.maximum_output_bytes) is not int + or not 1 <= self.maximum_output_bytes <= MAX_CHECKER_MODEL_OUTPUT_BYTES + or type(self.request_timeout_seconds) is not float + or not math.isfinite(self.request_timeout_seconds) + or not 0.1 <= self.request_timeout_seconds <= 3_600.0 + or self.reasoning_effort not in _REASONING_EFFORTS + or self.text_verbosity not in _TEXT_VERBOSITIES + ): + raise ValueError("OpenAI checker model settings are invalid") + + +class _InputTokenResource(Protocol): + async def count(self, **kwargs: Any) -> object: + """Count the exact request input without creating a response.""" + + +class _ResponsesResource(Protocol): + input_tokens: _InputTokenResource + + async def create(self, **kwargs: Any) -> object: + """Create one stored background response.""" + + async def retrieve(self, response_id: str, **kwargs: Any) -> object: + """Retrieve one already known response.""" + + +class _OpenAIClient(Protocol): + responses: _ResponsesResource + + async def close(self) -> None: + """Close client transport resources.""" + + +class OpenAIResponsesCheckerModelProvider: + """Translate one fixed profile to stored OpenAI background Responses calls.""" + + __slots__ = ("_client", "_settings") + + def __init__(self, settings: OpenAIResponsesSettings, client: _OpenAIClient) -> None: + if type(settings) is not OpenAIResponsesSettings: + raise ValueError("OpenAI checker model settings are invalid") + if not hasattr(client, "responses") or not callable(getattr(client, "close", None)): + raise ValueError("OpenAI checker model client is invalid") + self._settings = settings + self._client = client + + @property + def identity(self) -> ExternalServiceAdapterIdentity: + """Return the fixed identity checked by the shared typed factory.""" + return _IDENTITY + + async def create(self, request: CheckerModelRequest) -> CheckerModelCreateResult: + """Count first, then dispatch one create without any adapter retry.""" + if type(request) is not CheckerModelRequest: + raise ValueError("checker model request is invalid") + if len(request.prompt.encode("utf-8")) > self._settings.maximum_request_bytes: + return CheckerModelCreateResult( + CheckerModelCreateOutcome.NO_EFFECT, + failure=CheckerModelCreateFailure.REQUEST_BYTES_EXCEEDED, + ) + try: + counted = await self._client.responses.input_tokens.count( + **self._request_parameters(request.prompt) + ) + input_tokens = getattr(counted, "input_tokens", None) + if ( + type(input_tokens) is not int + or not 0 <= input_tokens <= MAX_CHECKER_MODEL_TOKEN_COUNT + ): + raise ValueError("provider input count is invalid") + except Exception: + return CheckerModelCreateResult( + CheckerModelCreateOutcome.NO_EFFECT, + failure=CheckerModelCreateFailure.INPUT_COUNT_UNAVAILABLE, + ) + if input_tokens > self._settings.maximum_input_tokens: + return CheckerModelCreateResult( + CheckerModelCreateOutcome.NO_EFFECT, + failure=CheckerModelCreateFailure.INPUT_LIMIT_EXCEEDED, + ) + + try: + response = await self._client.responses.create( + **self._request_parameters(request.prompt), + background=True, + store=True, + service_tier="default", + max_output_tokens=self._settings.maximum_output_tokens, + ) + except Exception: + return CheckerModelCreateResult( + CheckerModelCreateOutcome.UNCERTAIN, + failure=CheckerModelCreateFailure.DELIVERY_UNCERTAIN, + ) + + known_response = _known_response(response) + try: + observation = self._observation(response) + except (TypeError, ValueError, UnicodeError): + return CheckerModelCreateResult( + CheckerModelCreateOutcome.UNCERTAIN, + known_response=known_response, + failure=CheckerModelCreateFailure.RESPONSE_INVALID, + ) + return CheckerModelCreateResult( + CheckerModelCreateOutcome.ACCEPTED, + observation=observation, + known_response=observation.reference, + ) + + async def recover(self, reference: CheckerModelResponseReference) -> CheckerModelRecoveryResult: + """Retrieve the known provider ID once and never issue create.""" + if type(reference) is not CheckerModelResponseReference: + raise ValueError("checker model response identity is invalid") + try: + response = await self._client.responses.retrieve( + reference.provider_response_id, + ) + except Exception: + return CheckerModelRecoveryResult( + CheckerModelRecoveryOutcome.UNAVAILABLE, + reference, + ) + try: + observation = self._observation(response) + if observation.reference != reference: + raise ValueError("provider response identity changed") + except (TypeError, ValueError, UnicodeError): + return CheckerModelRecoveryResult( + CheckerModelRecoveryOutcome.INVALID, + reference, + ) + return CheckerModelRecoveryResult( + CheckerModelRecoveryOutcome.OBSERVED, + reference, + observation, + ) + + async def aclose(self) -> None: + """Close the exact SDK client created for this profile adapter.""" + await self._client.close() + + def _request_parameters(self, prompt: str) -> dict[str, object]: + return { + "model": self._settings.model, + "input": prompt, + "reasoning": {"effort": self._settings.reasoning_effort}, + "text": {"verbosity": self._settings.text_verbosity}, + "parallel_tool_calls": False, + "tools": [], + "truncation": "disabled", + "timeout": self._settings.request_timeout_seconds, + } + + def _observation(self, response: object) -> CheckerModelObservation: + reference = CheckerModelResponseReference(getattr(response, "id", None)) + if getattr(response, "service_tier", None) != "default": + raise ValueError("provider pricing tier differs from the locked standard rates") + provider_model = getattr(response, "model", None) + if provider_model != self._settings.model: + raise ValueError("provider model identity changed") + status = CheckerModelProviderStatus(getattr(response, "status", None)) + output = getattr(response, "output", None) + if type(output) is not list: + raise ValueError("provider output is invalid") + output_text = None if not output else getattr(response, "output_text", None) + if output_text is not None: + if not isinstance(output_text, str): + raise ValueError("provider output is invalid") + if len(output_text.encode("utf-8")) > self._settings.maximum_output_bytes: + raise ValueError("provider output exceeds profile bound") + usage_value = getattr(response, "usage", None) + usage = None if usage_value is None else _usage(usage_value) + if usage is not None and ( + usage.input_tokens > self._settings.maximum_input_tokens + or usage.output_tokens > self._settings.maximum_output_tokens + ): + raise ValueError("provider usage exceeds profile bound") + return CheckerModelObservation( + reference=reference, + provider_model=provider_model, + status=status, + output_text=output_text, + usage=usage, + ) + + +def create_checker_model_provider( + settings: OpenAIResponsesSettings, +) -> CheckerModelProvider: + """Construct the sole explicitly registered provider for one locked profile.""" + from openai import AsyncOpenAI + + factory = ExternalServiceAdapterFactory[CheckerModelProvider](CHECKER_MODEL_CAPABILITY_KEY) + factory.register( + OPENAI_RESPONSES_PROVIDER_KEY, + lambda: OpenAIResponsesCheckerModelProvider( + settings, + AsyncOpenAI( + api_key=settings.api_key, + max_retries=0, + timeout=settings.request_timeout_seconds, + ), + ), + ) + return factory.create(OPENAI_RESPONSES_PROVIDER_KEY) + + +def _known_response(response: object) -> CheckerModelResponseReference | None: + try: + return CheckerModelResponseReference(getattr(response, "id", None)) + except (TypeError, ValueError): + return None + + +def _usage(value: object) -> CheckerModelUsage: + input_details = getattr(value, "input_tokens_details", None) + output_details = getattr(value, "output_tokens_details", None) + return CheckerModelUsage( + input_tokens=getattr(value, "input_tokens", None), + cached_input_tokens=getattr(input_details, "cached_tokens", None), + output_tokens=getattr(value, "output_tokens", None), + reasoning_tokens=getattr(output_details, "reasoning_tokens", None), + total_tokens=getattr(value, "total_tokens", None), + ) diff --git a/backend/app/adapters/checkers/model_proxy_transport.py b/backend/app/adapters/checkers/model_proxy_transport.py new file mode 100644 index 000000000..a68cadadc --- /dev/null +++ b/backend/app/adapters/checkers/model_proxy_transport.py @@ -0,0 +1,344 @@ +"""Private bounded Unix transport for request-bound checker model calls.""" + +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +import stat +import struct +from typing import Any + +from app.interfaces.checker_model_proxy import ( + MODEL_PROXY_MAXIMUM_FRAME_BYTES, + MODEL_PROXY_PROTOCOL, + ModelProxyHandler, + ModelProxyReply, + ModelProxyRequest, +) + + +class ModelProxyTransportError(RuntimeError): + """Base error for model-proxy transport ownership and lifecycle failures.""" + + +class ModelProxyCustodyError(ModelProxyTransportError): + """The configured private socket path is not safe to own.""" + + +class ModelProxyCleanupError(ModelProxyTransportError): + """The transport cannot prove that the remaining socket path is its own.""" + + +class ModelProxyTransport: + """Own one private AF_UNIX listener and retain every dispatched handler.""" + + def __init__( + self, + *, + socket_path: Path, + handler: ModelProxyHandler, + maximum_connections: int = 16, + read_timeout_seconds: float = 2.0, + write_timeout_seconds: float = 2.0, + shutdown_timeout_seconds: float = 30.0, + ) -> None: + if ( + not isinstance(socket_path, Path) + or not callable(handler) + or type(maximum_connections) is not int + or not 1 <= maximum_connections <= 256 + or type(read_timeout_seconds) is not float + or not 0.01 <= read_timeout_seconds <= 60.0 + or type(write_timeout_seconds) is not float + or not 0.01 <= write_timeout_seconds <= 60.0 + or type(shutdown_timeout_seconds) is not float + or not 0.01 <= shutdown_timeout_seconds <= 7_200.0 + ): + raise ValueError("model proxy transport configuration is invalid") + self._socket_path = socket_path + self._handler = handler + self._maximum_connections = maximum_connections + self._read_timeout_seconds = read_timeout_seconds + self._write_timeout_seconds = write_timeout_seconds + self._shutdown_timeout_seconds = shutdown_timeout_seconds + self._server: asyncio.AbstractServer | None = None + self._connection_tasks: set[asyncio.Task[None]] = set() + self._handler_tasks: set[asyncio.Task[ModelProxyReply]] = set() + self._parent_identity: tuple[int, int] | None = None + self._socket_identity: tuple[int, int] | None = None + self._closing = False + + @property + def socket_path(self) -> Path: + return self._socket_path + + async def start(self) -> None: + """Bind only an absent path beneath its canonical same-UID private parent.""" + if self._server is not None or self._closing: + raise ModelProxyTransportError("model proxy transport already started") + parent = self._validated_parent() + try: + os.lstat(self._socket_path) + except FileNotFoundError: + pass + else: + raise ModelProxyCustodyError("model proxy socket path is occupied") + + server: asyncio.AbstractServer | None = None + created_identity: tuple[int, int] | None = None + try: + server = await asyncio.start_unix_server( + self._admit_connection, + path=self._socket_path, + backlog=self._maximum_connections, + limit=MODEL_PROXY_MAXIMUM_FRAME_BYTES + 4, + ) + metadata = os.lstat(self._socket_path) + created_identity = (metadata.st_dev, metadata.st_ino) + if not stat.S_ISSOCK(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode): + raise ModelProxyCustodyError("model proxy socket identity is invalid") + os.chmod(self._socket_path, 0o600, follow_symlinks=False) + metadata = os.lstat(self._socket_path) + if not _private_socket(metadata): + raise ModelProxyCustodyError("model proxy socket custody is invalid") + if created_identity != (metadata.st_dev, metadata.st_ino): + raise ModelProxyCustodyError("model proxy socket identity changed") + except BaseException: + if server is not None: + server.close() + await server.wait_closed() + if created_identity is not None: + self._unlink_owned(created_identity, parent) + raise + self._parent_identity = parent + self._socket_identity = created_identity + self._server = server + + async def close(self) -> None: + """Stop admission, drain retained work, then unlink only the owned inode.""" + self._closing = True + if self._server is not None: + self._server.close() + deadline = asyncio.get_running_loop().time() + self._shutdown_timeout_seconds + while self._connection_tasks or self._handler_tasks: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError("model proxy handler drain timed out") + tasks: set[asyncio.Task[Any]] = { + *self._connection_tasks, + *self._handler_tasks, + } + done, pending = await asyncio.wait(tasks, timeout=remaining) + self._connection_tasks.difference_update(done) + self._handler_tasks.difference_update(done) + if pending: + raise TimeoutError("model proxy handler drain timed out") + if self._server is not None: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError("model proxy listener drain timed out") + async with asyncio.timeout(remaining): + await self._server.wait_closed() + self._server = None + if self._socket_identity is not None and self._parent_identity is not None: + self._unlink_owned(self._socket_identity, self._parent_identity) + self._socket_identity = None + self._parent_identity = None + + async def __aenter__(self) -> ModelProxyTransport: + await self.start() + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + + def _validated_parent(self) -> tuple[int, int]: + path = self._socket_path + encoded = os.fsencode(path) + if ( + not path.is_absolute() + or not encoded + or b"\0" in encoded + or len(encoded) >= 108 + or path.name in {"", ".", ".."} + ): + raise ModelProxyCustodyError("model proxy socket path is invalid") + parent = path.parent + try: + metadata = os.lstat(parent) + canonical = parent.resolve(strict=True) + except OSError as exc: + raise ModelProxyCustodyError("model proxy parent is unavailable") from exc + if canonical != parent or not _private_directory(metadata): + raise ModelProxyCustodyError("model proxy parent custody is invalid") + return (metadata.st_dev, metadata.st_ino) + + def _admit_connection( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + if ( + self._closing + or len(self._connection_tasks) >= self._maximum_connections + or len(self._handler_tasks) >= self._maximum_connections + ): + writer.close() + return + task = asyncio.create_task( + self._serve_connection(reader, writer), + name="checker-model-proxy-connection", + ) + self._connection_tasks.add(task) + task.add_done_callback(self._connection_finished) + + def _connection_finished(self, task: asyncio.Task[None]) -> None: + self._connection_tasks.discard(task) + if not task.cancelled(): + task.exception() + + def _handler_finished(self, task: asyncio.Task[ModelProxyReply]) -> None: + self._handler_tasks.discard(task) + if not task.cancelled(): + task.exception() + + async def _serve_connection( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + try: + async with asyncio.timeout(self._read_timeout_seconds): + header = await reader.readexactly(4) + size = struct.unpack(">I", header)[0] + if size == 0 or size > MODEL_PROXY_MAXIMUM_FRAME_BYTES: + return + payload = await reader.readexactly(size) + request = _decode_request(payload) + try: + awaitable = self._handler(request) + except BaseException: + return + handler_task = asyncio.create_task( + awaitable, + name="checker-model-proxy-handler", + ) + self._handler_tasks.add(handler_task) + handler_task.add_done_callback(self._handler_finished) + try: + reply = await asyncio.shield(handler_task) + except BaseException: + return + if type(reply) is not ModelProxyReply: + return + body = json.dumps( + reply.wire_value(), + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + if not body or len(body) > MODEL_PROXY_MAXIMUM_FRAME_BYTES: + return + frame = struct.pack(">I", len(body)) + body + writer.write(frame) + async with asyncio.timeout(self._write_timeout_seconds): + await writer.drain() + except ( + TimeoutError, + OSError, + asyncio.IncompleteReadError, + UnicodeError, + ValueError, + json.JSONDecodeError, + ): + return + finally: + writer.close() + try: + async with asyncio.timeout(self._write_timeout_seconds): + await writer.wait_closed() + except (TimeoutError, OSError): + pass + + def _unlink_owned( + self, + socket_identity: tuple[int, int], + parent_identity: tuple[int, int], + ) -> None: + try: + parent = os.lstat(self._socket_path.parent) + except FileNotFoundError: + raise ModelProxyCleanupError("model proxy parent disappeared") from None + if (parent.st_dev, parent.st_ino) != parent_identity or not _private_directory(parent): + raise ModelProxyCleanupError("model proxy parent identity changed") + try: + current = os.lstat(self._socket_path) + except FileNotFoundError: + return + if (current.st_dev, current.st_ino) != socket_identity or not _private_socket(current): + raise ModelProxyCleanupError("model proxy socket identity changed") + try: + self._socket_path.unlink() + except OSError as exc: + raise ModelProxyCleanupError("model proxy socket cleanup failed") from exc + + +def _decode_request(payload: bytes) -> ModelProxyRequest: + value = json.loads( + payload.decode("utf-8"), + parse_constant=_reject_json_constant, + object_pairs_hook=_closed_json_object, + ) + expected = {"protocol_version", "grant_id", "request_digest", "prompt"} + if type(value) is not dict or set(value) != expected: + raise ValueError("model proxy request root is invalid") + if value["protocol_version"] != MODEL_PROXY_PROTOCOL: + raise ValueError("model proxy protocol version is invalid") + return ModelProxyRequest( + grant_id=value["grant_id"], + request_digest=value["request_digest"], + prompt=value["prompt"], + ) + + +def _closed_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise ValueError("duplicate model proxy field") + value[key] = item + return value + + +def _reject_json_constant(_: str) -> None: + raise ValueError("non-finite model proxy number") + + +def _private_directory(metadata: os.stat_result) -> bool: + return ( + stat.S_ISDIR(metadata.st_mode) + and not stat.S_ISLNK(metadata.st_mode) + and stat.S_IMODE(metadata.st_mode) == 0o700 + and metadata.st_uid == os.geteuid() + and metadata.st_gid == os.getegid() + ) + + +def _private_socket(metadata: os.stat_result) -> bool: + return ( + stat.S_ISSOCK(metadata.st_mode) + and not stat.S_ISLNK(metadata.st_mode) + and stat.S_IMODE(metadata.st_mode) == 0o600 + and metadata.st_uid == os.geteuid() + and metadata.st_gid == os.getegid() + ) + + +__all__ = ( + "ModelProxyCleanupError", + "ModelProxyCustodyError", + "ModelProxyTransport", + "ModelProxyTransportError", +) diff --git a/backend/app/adapters/checkers/model_runtime.py b/backend/app/adapters/checkers/model_runtime.py new file mode 100644 index 000000000..594e715a8 --- /dev/null +++ b/backend/app/adapters/checkers/model_runtime.py @@ -0,0 +1,103 @@ +"""Compose the private model listener with the existing AUTH, CHECKERS and ART owners.""" + +from contextlib import asynccontextmanager +import os + +from app.adapters.artifacts import ( + artifact_provider_activation_guard, + checker_output_binding, + checker_output_storage, + create_artifact_scratch_manager, + create_artifact_store_bootstrap, +) +from app.adapters.auth import checker_model_service_authorization +from app.adapters.checkers import model_call_service, model_invocation_grants, model_profile_reader +from app.adapters.checkers.model_provider import ( + OpenAIResponsesSettings, + create_checker_model_provider, +) +from app.adapters.checkers.model_proxy_transport import ModelProxyTransport +from app.modules.artifacts.preparation import ArtifactPreparationService +from app.modules.artifacts.service import ( + artifact_storage_namespace_spec, + validate_artifact_storage_namespace_at_startup, +) +from app.modules.checkers.api.model_calls import ModelCallUnavailable, ProjectModelProfile +from app.modules.checkers.model_calls.proxy import CheckerModelProxy + + +@asynccontextmanager +async def configured_model_provider(profile: ProjectModelProfile): + """Construct exactly the provider selected by a trusted immutable profile.""" + spec = profile.spec + if (spec.provider, spec.api) != ("openai_responses", "responses.v1"): + raise ModelCallUnavailable("model_profile_provider_unavailable") + key = os.environ.get("OPENAI_API_KEY", "") + if not key: + raise ModelCallUnavailable("model_provider_credentials_unavailable") + provider = create_checker_model_provider( + OpenAIResponsesSettings( + api_key=key, + model=spec.model_snapshot, + maximum_request_bytes=spec.maximum_request_bytes, + maximum_input_tokens=spec.maximum_input_tokens, + maximum_output_tokens=spec.maximum_output_tokens, + maximum_output_bytes=spec.maximum_output_bytes, + request_timeout_seconds=float(spec.deadline_ms / 1000), + reasoning_effort=spec.settings.reasoning_effort, + text_verbosity=spec.settings.text_verbosity, + ) + ) + try: + yield provider + finally: + await provider.aclose() + + +@asynccontextmanager +async def configured_model_proxy_runtime(settings, sessions): + """Own one optional listener and retain its storage resources until handlers drain.""" + if settings.checker_model_proxy_socket is None: + yield + return + bootstrap = create_artifact_store_bootstrap( + settings, + activation_guard=artifact_provider_activation_guard(settings, sessions), + ) + manager = None + try: + claim = await validate_artifact_storage_namespace_at_startup( + bootstrap, + settings, + sessions, + ) + store = bootstrap.initialize_after_namespace_claim(claim) + namespace = artifact_storage_namespace_spec(settings, bootstrap) + manager = create_artifact_scratch_manager(settings) + outputs = checker_output_storage( + sessions=sessions, + store=store, + namespace=namespace, + preparation=ArtifactPreparationService(manager), + settings=settings, + ) + proxy = CheckerModelProxy( + sessions=sessions, + authorization=checker_model_service_authorization, + grants=model_invocation_grants, + calls=model_call_service, + profiles=model_profile_reader, + outputs=outputs, + bindings=lambda session: checker_output_binding(session, namespace=namespace), + provider=configured_model_provider, + ) + async with ModelProxyTransport( + socket_path=settings.checker_model_proxy_socket, + handler=proxy.handle, + shutdown_timeout_seconds=7200.0, + ): + yield + finally: + if manager is not None: + manager.close() + bootstrap.close() diff --git a/backend/app/adapters/projects/__init__.py b/backend/app/adapters/projects/__init__.py index 64cff6b7b..74794f14f 100644 --- a/backend/app/adapters/projects/__init__.py +++ b/backend/app/adapters/projects/__init__.py @@ -161,10 +161,15 @@ def project_guide_proposal_service( ExternalCheckerCapabilityProjection, ]: """Compose the sole proposal owner with explicit authority.""" - from app.adapters.checkers import external_checker_registry_reader + from app.adapters.checkers import external_checker_registry_reader, model_profile_reader from app.modules.projects.guide_compilation.proposal_service import GuideProposalService - return GuideProposalService(session, authorization, external_checker_registry_reader(session)) + return GuideProposalService( + session, + authorization, + external_checker_registry_reader(session), + model_profile_reader(session), + ) def project_guide_correction_dispatch( @@ -236,7 +241,7 @@ def project_guide_activation_port( authorization: GuideActivationAuthorizationPort | None = None, ) -> GuideActivationPort[ActorIdentityFacts]: """Compose canonical activation with explicit CON validation and authority.""" - from app.adapters.checkers import external_checker_registry_reader + from app.adapters.checkers import external_checker_registry_reader, model_profile_reader from app.modules.projects.guide_activation.service import GuideActivationService return GuideActivationService( @@ -244,6 +249,7 @@ def project_guide_activation_port( contribution=contribution, authorization=authorization, registry=external_checker_registry_reader(session), + profiles=model_profile_reader(session), ) @@ -253,13 +259,14 @@ def project_guide_activation_readiness_port( contribution: GuideContributionPolicyPort, ) -> GuideActivationReadinessPort: """Compose activation's own conditions for a dry run; no activation authority is bound.""" - from app.adapters.checkers import external_checker_registry_reader + from app.adapters.checkers import external_checker_registry_reader, model_profile_reader from app.modules.projects.guide_activation.service import GuideActivationService return GuideActivationService( session, contribution=contribution, registry=external_checker_registry_reader(session), + profiles=model_profile_reader(session), ) diff --git a/backend/app/adapters/tasks/__init__.py b/backend/app/adapters/tasks/__init__.py index 16650488a..d8b68129b 100644 --- a/backend/app/adapters/tasks/__init__.py +++ b/backend/app/adapters/tasks/__init__.py @@ -510,7 +510,7 @@ def validate_submission_policy_context( def evaluation_request_handler(*, sessions, materialization): """Compose one hidden TASK request handler; production registration is separate.""" from app.adapters.checkers import ( - configured_external_checker_execution, + configured_post_submit_external_execution, delivery_bound_post_submission_executor, evaluation_coordinator, ) @@ -519,7 +519,7 @@ def evaluation_request_handler(*, sessions, materialization): from app.modules.tasks.evaluation_delivery import EvaluationRequestHandler def executor(envelope, request): - selected_execution = configured_external_checker_execution(get_settings()) + selected_execution = configured_post_submit_external_execution(get_settings(), sessions=sessions) return delivery_bound_post_submission_executor( sessions=sessions, materialization=materialization, diff --git a/backend/app/api/deps/checker_models.py b/backend/app/api/deps/checker_models.py new file mode 100644 index 000000000..3a8ae0133 --- /dev/null +++ b/backend/app/api/deps/checker_models.py @@ -0,0 +1,192 @@ +"""Authenticated public composition for Operator-owned checker model controls.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Annotated +from uuid import UUID + +from fastapi import Depends, Request +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.adapters.auth import checker_model_authorization +from app.adapters.checkers import model_call_service +from app.api.deps.auth import get_auth_verification_result +from app.api.deps.authorization import ( + ResolvedActor, + _authorization_context, + get_authorization_actor, +) +from app.core.api_controls import StructuredHTTPException, parse_idempotency_key, request_ids +from app.core.hashing import canonical_json_hash +from app.db.session import get_db_session +from app.modules.checkers.api.model_calls import ( + ModelBudgetProvisioningFacts, + ModelCallBudgetUnavailable, + ModelCallConflict, + ModelCallUnavailable, + ModelProfileProvisioningFacts, + ModelRecoveryChildRequest, + PreparedModelCallAttempt, + ProjectModelBudgetConfiguration, + ProjectModelBudgetConfigurationSpec, + ProjectModelProfile, + ProjectModelProfileSpec, +) +from app.modules.checkers.model_calls.service import ModelCallService +from app.schemas.auth import AuthVerificationResult + + +MODEL_ADMIN_IDEMPOTENCY_PARAMETER = { + "name": "Idempotency-Key", + "in": "header", + "required": True, + "schema": {"type": "string", "format": "uuid"}, +} + + +def require_model_admin_key(request: Request) -> UUID: + """Reject an absent, duplicate or noncanonical Operator mutation key.""" + values = request.headers.getlist("Idempotency-Key") + return parse_idempotency_key(values[0] if len(values) == 1 else "") + + +def _error(status_code: int) -> StructuredHTTPException: + code, message = { + 404: ("checker_model_control_not_found", "Checker model control not found"), + 409: ("checker_model_control_conflict", "Checker model control conflicts"), + 503: ("checker_model_control_unavailable", "Checker model control unavailable"), + }[status_code] + return StructuredHTTPException( + status_code=status_code, + detail=code, + error_code=code, + error_message=message, + retryable=status_code == 503, + ) + + +async def require_model_admin_human( + operation_id: Annotated[UUID, Depends(require_model_admin_key)], + result: Annotated[AuthVerificationResult, Depends(get_auth_verification_result)], +) -> None: + """Consume the key first and conceal every nonhuman public mutation caller.""" + del operation_id + if result.token.subject_kind != "human": + raise _error(404) + + +@dataclass(frozen=True) +class CheckerModelAdminCommands: + """Bind path, actor and caller key to the existing atomic CHECKERS owner.""" + + service: ModelCallService + actor_profile_id: UUID + operation_id: UUID + + async def provision_profile( + self, project_id: UUID, spec: ProjectModelProfileSpec + ) -> ProjectModelProfile: + if spec.project_id != project_id: + raise ModelCallConflict("model_profile_project_conflict") + return await self.service.provision_profile( + ModelProfileProvisioningFacts( + operation_id=self.operation_id, + actor_profile_id=self.actor_profile_id, + spec=spec, + profile_digest=canonical_json_hash(spec.model_dump(mode="json")), + ) + ) + + async def configure_budget( + self, project_id: UUID, spec: ProjectModelBudgetConfigurationSpec + ) -> ProjectModelBudgetConfiguration: + if spec.project_id != project_id: + raise ModelCallConflict("model_budget_project_conflict") + return await self.service.provision_budget( + ModelBudgetProvisioningFacts( + operation_id=self.operation_id, + actor_profile_id=self.actor_profile_id, + spec=spec, + configuration_digest=canonical_json_hash(spec.model_dump(mode="json")), + ) + ) + + async def prepare_recovery_child( + self, + project_id: UUID, + parent_attempt_id: UUID, + request: ModelRecoveryChildRequest, + ) -> PreparedModelCallAttempt: + if request.execution.project_id != project_id or request.slot.project_id != project_id: + raise ModelCallConflict("model_recovery_project_conflict") + return await self.service.prepare_recovery_child( + request.execution, + request.slot, + registry_entry_id=request.registry_entry_id, + parent_attempt_id=parent_attempt_id, + terminal_digest=request.terminal_digest, + request_sha256=request.request_sha256, + ) + + +@asynccontextmanager +async def _model_admin_operation( + request: Request, + operation_id: UUID, + resolved: ResolvedActor, + session: AsyncSession, +) -> AsyncIterator[CheckerModelAdminCommands]: + request_id, _ = (UUID(value) for value in request_ids(request)) + context = _authorization_context(resolved, request_id, operation_id) + await session.rollback() + try: + async with session.begin(): + authority = checker_model_authorization(session, context) + try: + yield CheckerModelAdminCommands( + service=model_call_service(session, authority), + actor_profile_id=context.actor_profile_id, + operation_id=operation_id, + ) + except ModelCallUnavailable as exc: + # AUTH has already staged its canonical denial. Preserve that evidence + # before the public boundary conceals the private authority reason. + if str(exc) == "model_call_authority_unavailable": + await session.commit() + raise + except ModelCallBudgetUnavailable as exc: + raise _error(409) from exc + except ModelCallConflict as exc: + raise _error(409) from exc + except ModelCallUnavailable as exc: + status_code = 404 if str(exc) == "model_call_authority_unavailable" else 503 + raise _error(status_code) from exc + except SQLAlchemyError as exc: + raise _error(503) from exc + finally: + if session.in_transaction(): + await session.rollback() + + +async def get_checker_model_admin_commands( + request: Request, + operation_id: Annotated[UUID, Depends(require_model_admin_key)], + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> AsyncIterator[CheckerModelAdminCommands]: + """Compose one human Operator mutation and its root transaction.""" + async with _model_admin_operation(request, operation_id, resolved, session) as commands: + yield commands + + +__all__ = ( + "MODEL_ADMIN_IDEMPOTENCY_PARAMETER", + "CheckerModelAdminCommands", + "get_checker_model_admin_commands", + "require_model_admin_human", + "require_model_admin_key", +) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e0c17fee8..062852a2b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -289,12 +289,19 @@ class Settings(BaseSettings): ) external_checker_service_socket: Path | None = None external_checker_material_root: Path | None = None + checker_model_proxy_socket: Path | None = None external_checker_service_timeout_seconds: float = Field( default=3660.0, gt=0.0, le=3700.0, ) + @field_validator("checker_model_proxy_socket", mode="before") + @classmethod + def empty_model_proxy_socket(cls, value: object) -> object: + """Allow the local compose overlay to leave model execution disabled.""" + return None if value == "" else value + artifact_pending_work_scan_interval_seconds: int = Field(default=60, gt=0, le=3600) artifact_pending_work_scan_page_size: int = Field(default=100, gt=0, le=1000) guide_setup_continuation_scan_page_size: int = Field(default=100, gt=0, le=1000) @@ -676,6 +683,11 @@ def validate_artifact_storage(self) -> Settings: raise ValueError( "external checker material root must equal the artifact scratch root" ) + if self.checker_model_proxy_socket is not None: + if not self.checker_model_proxy_socket.is_absolute(): + raise ValueError("checker model proxy socket must be absolute") + if self.artifact_store_backend == "disabled": + raise ValueError("checker model proxy requires enabled artifact storage") if self.artifact_store_backend == "disabled": return self if any( diff --git a/backend/app/core/project_guide_instructions.py b/backend/app/core/project_guide_instructions.py index 61cd4d091..957176af7 100644 --- a/backend/app/core/project_guide_instructions.py +++ b/backend/app/core/project_guide_instructions.py @@ -121,10 +121,9 @@ not a pass unless the guide explicitly makes disclosure sufficient. Warnings apply only to nonblocking guidance or platform warnings. -Specifications grant no access. Current offline checker images receive verified -package bytes, manifest, approved configuration and normalized request metadata, -not task or guide prose. Task and guide context, hosted models and nested sandboxes -are unavailable runtime_gap dependencies. A supported binding cannot claim them. +Offline checker images receive verified package bytes, manifest, configuration +and request metadata, not task or guide prose. Task and guide context and nested +sandboxes are unavailable runtime_gap dependencies. A supported binding cannot claim them. current_runner means isolation of the checker image itself. It does not supply a container engine, task-defined environment, image cache or a second sandbox. Building and running a submitted container environment or independently isolated @@ -134,10 +133,10 @@ Declare runtime_dependencies explicitly, including dependencies implied by the evaluation method. evaluation_method isolated_execution requires an entry with kind isolated_execution, provisioning current_runner, and unavailable_code -implementation_unavailable. evaluation_method model_evaluation requires an entry -with kind model_service, provisioning runtime_gap, and unavailable_code -implementation_unavailable; model evaluation always belongs to POST background -work even if a proposed duration is below 60 seconds. +implementation_unavailable. model_evaluation requires model_service, model_proxy +and implementation_unavailable and always belongs to POST background work. Model +access requires a registered project image plus its exact approved profile; if +either is missing, keep an unbound blocking implementation suggestion. checker_tool uses checker_implementation and implementation_unavailable; nested_sandbox uses runtime_gap and implementation_unavailable. task_context, diff --git a/backend/app/db/models.py b/backend/app/db/models.py index af769812e..260e056ec 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -123,3 +123,9 @@ from app.modules.reviews.lifecycle.models import JointLifecycleReleaseControl # noqa: F401 from app.modules.tasks.revision.models import TaskRevisionPreparation, TaskRevisionExpiration, TaskRevisionExhaustion # noqa: F401 + +from app.modules.checkers.model_calls.models import ( # noqa: F401 + CheckerModelProfile, CheckerModelBudgetAccount, CheckerModelBudgetConfiguration, + CheckerModelCallSlot, CheckerModelCallAttempt, CheckerModelCallRecovery, +) +from app.modules.checkers.model_calls.grants import CheckerModelInvocationGrant # noqa: F401 diff --git a/backend/app/interfaces/checker_model_provider.py b/backend/app/interfaces/checker_model_provider.py new file mode 100644 index 000000000..e6a442d46 --- /dev/null +++ b/backend/app/interfaces/checker_model_provider.py @@ -0,0 +1,243 @@ +"""Typed provider boundary for one policy-authorized checker model call.""" + +from __future__ import annotations + +from dataclasses import dataclass, field as dataclass_field +from enum import StrEnum +import re +from typing import Protocol + +from app.interfaces.external_services import ExternalServiceAdapter + + +CHECKER_MODEL_CAPABILITY_KEY = "checker_model_generation" +MAX_CHECKER_MODEL_PROMPT_BYTES = 1 * 1024 * 1024 +MAX_CHECKER_MODEL_OUTPUT_BYTES = 1 * 1024 * 1024 +MAX_CHECKER_MODEL_PROVIDER_ID_LENGTH = 256 +MAX_CHECKER_MODEL_NAME_LENGTH = 128 +MAX_CHECKER_MODEL_TOKEN_COUNT = 2**63 - 1 + +_PROVIDER_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") + + +class CheckerModelProviderStatus(StrEnum): + """Provider-owned state for one accepted response effect.""" + + QUEUED = "queued" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + INCOMPLETE = "incomplete" + + +class CheckerModelCreateOutcome(StrEnum): + """Effect classification for exactly one authorized create dispatch.""" + + ACCEPTED = "accepted" + NO_EFFECT = "no_effect" + UNCERTAIN = "uncertain" + + +class CheckerModelCreateFailure(StrEnum): + """Secret-safe reason for a create operation without an accepted observation.""" + + REQUEST_BYTES_EXCEEDED = "request_bytes_exceeded" + INPUT_COUNT_UNAVAILABLE = "input_count_unavailable" + INPUT_LIMIT_EXCEEDED = "input_limit_exceeded" + DELIVERY_UNCERTAIN = "delivery_uncertain" + RESPONSE_INVALID = "response_invalid" + + +class CheckerModelRecoveryOutcome(StrEnum): + """Observation classification for retrieval of one known provider response.""" + + OBSERVED = "observed" + UNAVAILABLE = "unavailable" + INVALID = "invalid" + + +@dataclass(frozen=True, slots=True) +class CheckerModelRequest: + """Bounded image-authored prompt without provider-controlled settings.""" + + prompt: str = dataclass_field(repr=False) + + def __post_init__(self) -> None: + if not isinstance(self.prompt, str) or not self.prompt: + raise ValueError("checker model prompt is invalid") + try: + encoded = self.prompt.encode("utf-8") + except UnicodeError: + raise ValueError("checker model prompt is invalid") from None + if len(encoded) > MAX_CHECKER_MODEL_PROMPT_BYTES: + raise ValueError("checker model prompt is invalid") + + +@dataclass(frozen=True, slots=True) +class CheckerModelResponseReference: + """One bounded provider response identity already known to Workstream.""" + + provider_response_id: str + + def __post_init__(self) -> None: + _require_provider_identifier( + self.provider_response_id, + maximum_length=MAX_CHECKER_MODEL_PROVIDER_ID_LENGTH, + label="checker model response identity", + ) + + +@dataclass(frozen=True, slots=True) +class CheckerModelUsage: + """Authenticated token facts; reasoning tokens remain part of output tokens.""" + + input_tokens: int + cached_input_tokens: int + output_tokens: int + reasoning_tokens: int + total_tokens: int + + def __post_init__(self) -> None: + values = ( + self.input_tokens, + self.cached_input_tokens, + self.output_tokens, + self.reasoning_tokens, + self.total_tokens, + ) + if any( + type(value) is not int or not 0 <= value <= MAX_CHECKER_MODEL_TOKEN_COUNT + for value in values + ): + raise ValueError("checker model usage is invalid") + if ( + self.cached_input_tokens > self.input_tokens + or self.reasoning_tokens > self.output_tokens + or self.total_tokens != self.input_tokens + self.output_tokens + ): + raise ValueError("checker model usage is invalid") + + +@dataclass(frozen=True, slots=True) +class CheckerModelObservation: + """Bounded provider response facts suitable for private ART evidence.""" + + reference: CheckerModelResponseReference + provider_model: str + status: CheckerModelProviderStatus + output_text: str | None = dataclass_field(default=None, repr=False) + usage: CheckerModelUsage | None = None + + def __post_init__(self) -> None: + if type(self.reference) is not CheckerModelResponseReference: + raise ValueError("checker model observation is invalid") + _require_provider_identifier( + self.provider_model, + maximum_length=MAX_CHECKER_MODEL_NAME_LENGTH, + label="checker model provider identity", + ) + if type(self.status) is not CheckerModelProviderStatus: + raise ValueError("checker model observation is invalid") + if self.output_text is not None: + if not isinstance(self.output_text, str): + raise ValueError("checker model observation is invalid") + try: + output_bytes = self.output_text.encode("utf-8") + except UnicodeError: + raise ValueError("checker model observation is invalid") from None + if len(output_bytes) > MAX_CHECKER_MODEL_OUTPUT_BYTES: + raise ValueError("checker model observation is invalid") + if self.usage is not None and type(self.usage) is not CheckerModelUsage: + raise ValueError("checker model observation is invalid") + if self.status in { + CheckerModelProviderStatus.QUEUED, + CheckerModelProviderStatus.IN_PROGRESS, + } and (self.output_text is not None or self.usage is not None): + raise ValueError("checker model observation is invalid") + if self.status is CheckerModelProviderStatus.COMPLETED and ( + self.output_text is None or self.usage is None + ): + raise ValueError("checker model observation is invalid") + + +@dataclass(frozen=True, slots=True) +class CheckerModelCreateResult: + """Sanitized result of one create attempt, including exact effect certainty.""" + + outcome: CheckerModelCreateOutcome + observation: CheckerModelObservation | None = None + known_response: CheckerModelResponseReference | None = None + failure: CheckerModelCreateFailure | None = None + + def __post_init__(self) -> None: + if type(self.outcome) is not CheckerModelCreateOutcome: + raise ValueError("checker model create result is invalid") + if self.outcome is CheckerModelCreateOutcome.ACCEPTED: + if ( + type(self.observation) is not CheckerModelObservation + or self.known_response != self.observation.reference + or self.failure is not None + ): + raise ValueError("checker model create result is invalid") + return + if self.observation is not None or type(self.failure) is not CheckerModelCreateFailure: + raise ValueError("checker model create result is invalid") + if self.outcome is CheckerModelCreateOutcome.NO_EFFECT: + if self.known_response is not None or self.failure not in { + CheckerModelCreateFailure.REQUEST_BYTES_EXCEEDED, + CheckerModelCreateFailure.INPUT_COUNT_UNAVAILABLE, + CheckerModelCreateFailure.INPUT_LIMIT_EXCEEDED, + }: + raise ValueError("checker model create result is invalid") + elif self.failure not in { + CheckerModelCreateFailure.DELIVERY_UNCERTAIN, + CheckerModelCreateFailure.RESPONSE_INVALID, + }: + raise ValueError("checker model create result is invalid") + + +@dataclass(frozen=True, slots=True) +class CheckerModelRecoveryResult: + """Sanitized result of retrieving one previously accepted provider response.""" + + outcome: CheckerModelRecoveryOutcome + reference: CheckerModelResponseReference + observation: CheckerModelObservation | None = None + + def __post_init__(self) -> None: + if ( + type(self.outcome) is not CheckerModelRecoveryOutcome + or type(self.reference) is not CheckerModelResponseReference + ): + raise ValueError("checker model recovery result is invalid") + if self.outcome is CheckerModelRecoveryOutcome.OBSERVED: + if ( + type(self.observation) is not CheckerModelObservation + or self.observation.reference != self.reference + ): + raise ValueError("checker model recovery result is invalid") + elif self.observation is not None: + raise ValueError("checker model recovery result is invalid") + + +class CheckerModelProvider(ExternalServiceAdapter, Protocol): + """Provider-specific transport behind durable Workstream call custody.""" + + async def create(self, request: CheckerModelRequest) -> CheckerModelCreateResult: + """Dispatch one authorized effect exactly once.""" + + async def recover(self, reference: CheckerModelResponseReference) -> CheckerModelRecoveryResult: + """Inspect one known effect without creating another.""" + + async def aclose(self) -> None: + """Close provider transport resources owned by this adapter.""" + + +def _require_provider_identifier(value: object, *, maximum_length: int, label: str) -> None: + if ( + not isinstance(value, str) + or len(value) > maximum_length + or _PROVIDER_IDENTIFIER.fullmatch(value) is None + ): + raise ValueError(f"{label} is invalid") diff --git a/backend/app/interfaces/checker_model_proxy.py b/backend/app/interfaces/checker_model_proxy.py new file mode 100644 index 000000000..a0c113c7a --- /dev/null +++ b/backend/app/interfaces/checker_model_proxy.py @@ -0,0 +1,112 @@ +"""Typed private protocol for request-bound checker model calls.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + + +MODEL_PROXY_PROTOCOL = "workstream.checker_model_proxy.v1" +MODEL_PROXY_MAXIMUM_FRAME_BYTES = 1024 * 1024 +_MODEL_GRANT_PREFIX = "model_" +_SHA256_PREFIX = "sha256:" + + +@dataclass(frozen=True, slots=True, repr=False) +class ModelProxyRequest: + """One strict relay request; repr never exposes its grant or prompt.""" + + grant_id: str + request_digest: str + prompt: str + + def __post_init__(self) -> None: + if ( + not _valid_model_grant(self.grant_id) + or not _valid_sha256(self.request_digest) + or type(self.prompt) is not str + ): + raise ValueError("model proxy request is invalid") + + def __repr__(self) -> str: + return "ModelProxyRequest()" + + +@dataclass(frozen=True, slots=True, repr=False) +class ModelProxyReply: + """Provider-neutral bounded reply returned to the checker image.""" + + outcome: str + output: str | None + receipt_digest: str | None + failure_code: str | None + + def __post_init__(self) -> None: + completed = ( + self.outcome == "completed" + and type(self.output) is str + and bool(self.output) + and _valid_sha256(self.receipt_digest) + and self.failure_code is None + ) + unavailable = ( + self.outcome == "unavailable" + and self.output is None + and self.receipt_digest is None + and _valid_failure_code(self.failure_code) + ) + if not completed and not unavailable: + raise ValueError("model proxy reply is invalid") + + def __repr__(self) -> str: + return f"ModelProxyReply(outcome={self.outcome!r}, )" + + def wire_value(self) -> dict[str, str | None]: + """Return the exact private protocol root without provider coordinates.""" + return { + "protocol_version": MODEL_PROXY_PROTOCOL, + "outcome": self.outcome, + "output": self.output, + "receipt_digest": self.receipt_digest, + "failure_code": self.failure_code, + } + + +ModelProxyHandler = Callable[[ModelProxyRequest], Awaitable[ModelProxyReply]] + + +def _valid_model_grant(value: object) -> bool: + return ( + type(value) is str + and len(value) == len(_MODEL_GRANT_PREFIX) + 32 + and value.startswith(_MODEL_GRANT_PREFIX) + and all(item in "0123456789abcdef" for item in value[len(_MODEL_GRANT_PREFIX) :]) + ) + + +def _valid_sha256(value: object) -> bool: + return ( + type(value) is str + and len(value) == len(_SHA256_PREFIX) + 64 + and value.startswith(_SHA256_PREFIX) + and all(item in "0123456789abcdef" for item in value[len(_SHA256_PREFIX) :]) + ) + + +def _valid_failure_code(value: object) -> bool: + return ( + type(value) is str + and 1 <= len(value) <= 64 + and value[0].islower() + and value[0].isascii() + and all( + item.isascii() and (item.islower() or item.isdigit() or item == "_") for item in value + ) + ) + + +__all__ = ( + "MODEL_PROXY_MAXIMUM_FRAME_BYTES", + "MODEL_PROXY_PROTOCOL", + "ModelProxyHandler", + "ModelProxyReply", + "ModelProxyRequest", +) diff --git a/backend/app/interfaces/external_checker_execution.py b/backend/app/interfaces/external_checker_execution.py index 536465a0b..3b9d11a6e 100644 --- a/backend/app/interfaces/external_checker_execution.py +++ b/backend/app/interfaces/external_checker_execution.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field import re from typing import Protocol @@ -37,6 +37,24 @@ def __post_init__(self) -> None: raise ValueError("external checker material grant is invalid") +@dataclass(frozen=True, slots=True) +class ExternalCheckerModelGrant: + """Opaque invocation relay selector, never sufficient authority to spend.""" + + grant_id: str = field(repr=False) + binding_digest: Sha256 + + def __post_init__(self) -> None: + """Keep the private relay identity closed and bound to one request.""" + if ( + type(self.grant_id) is not str + or re.fullmatch(r"model_[0-9a-f]{32}", self.grant_id) is None + or type(self.binding_digest) is not str + or re.fullmatch(r"sha256:[0-9a-f]{64}", self.binding_digest) is None + ): + raise ValueError("external checker model grant is invalid") + + @dataclass(frozen=True, slots=True) class ExternalCheckerIsolationReceipt: """Observed service-owned image and sandbox identity for one execution.""" @@ -124,6 +142,8 @@ async def execute( self, request: ExternalCheckerExecutionRequest, grant: ExternalCheckerMaterialGrant, + *, + model_grant: ExternalCheckerModelGrant | None = None, ) -> ExternalCheckerExecutionResponse: """Return a request-bound result or a sanitized transport failure.""" @@ -136,5 +156,6 @@ async def health(self) -> ExternalCheckerServiceHealth: "ExternalCheckerExecutionResponse", "ExternalCheckerIsolationReceipt", "ExternalCheckerMaterialGrant", + "ExternalCheckerModelGrant", "ExternalCheckerServiceHealth", ) diff --git a/backend/app/interfaces/project_agents.py b/backend/app/interfaces/project_agents.py index d978884cf..f4d7749ca 100644 --- a/backend/app/interfaces/project_agents.py +++ b/backend/app/interfaces/project_agents.py @@ -327,7 +327,9 @@ class PlatformCoverageRef(BaseModel): "execution_capacity", "prepared_material", ] -DependencyProvisioning = Literal["current_runner", "checker_implementation", "runtime_gap"] +DependencyProvisioning = Literal[ + "current_runner", "checker_implementation", "model_proxy", "runtime_gap" +] InfrastructureFailureCode = Literal[ "capacity_exceeded", "deadline_exceeded", @@ -441,10 +443,24 @@ def validate_provisioning(self) -> CheckRuntimeDependency: "task_context": ("runtime_gap", "material_unavailable"), "guide_context": ("runtime_gap", "material_unavailable"), "prepared_material": ("runtime_gap", "material_unavailable"), - "model_service": ("runtime_gap", "implementation_unavailable"), + # runtime_gap remains readable for immutable retained compilations. New + # generation admission below requires the precise model_proxy owner. + "model_service": ( + {"model_proxy", "runtime_gap"}, + "implementation_unavailable", + ), "execution_capacity": ("runtime_gap", "capacity_exceeded"), }[self.kind] - if (self.provisioning, self.unavailable_code) != expected: + expected_provisioning, expected_code = expected + if ( + self.provisioning + not in ( + expected_provisioning + if isinstance(expected_provisioning, set) + else {expected_provisioning} + ) + or self.unavailable_code != expected_code + ): raise ValueError("check dependency availability is invalid") return self @@ -947,6 +963,12 @@ def require_complete_generated_specifications( for dependency in specification.runtime_dependencies ): raise ValueError("supported check depends on unavailable runtime support") + if any( + dependency.kind == "model_service" + and dependency.provisioning != "model_proxy" + for dependency in specification.runtime_dependencies + ): + raise ValueError("model service requires model proxy provisioning") definitions = { (definition.phase, definition.capability_id, definition.capability_version): definition diff --git a/backend/app/modules/artifacts/checker_model_evidence.py b/backend/app/modules/artifacts/checker_model_evidence.py new file mode 100644 index 000000000..ab6aef07c --- /dev/null +++ b/backend/app/modules/artifacts/checker_model_evidence.py @@ -0,0 +1,113 @@ +"""Verified ART metadata consumed by the model-call ledger, without transcript bytes.""" + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.artifacts.models import ( + ArtifactBinding, + ArtifactContent, + ArtifactPutAttempt, + ArtifactReplica, + ArtifactVerificationJob, + ArtifactVerificationReceipt, +) +from app.modules.checkers.api.model_calls import ( + ModelCallEvidence, + ModelCallUnavailable, + PreparedModelCallAttempt, +) + + +class CheckerModelEvidence: + """Require immutable verified binding ancestry in the caller's transaction.""" + + def __init__(self, session: AsyncSession): + self._session = session + + async def require_request( + self, + attempt: PreparedModelCallAttempt, + evidence: ModelCallEvidence, + ) -> None: + """Verify the immutable request under the owner's exact prepared attempt.""" + if evidence.content_sha256 != attempt.request_sha256: + raise ModelCallUnavailable("model_call_evidence_unavailable") + await self._require(attempt.attempt_id, "model_request", evidence) + + async def require_response(self, attempt_id: UUID, evidence: ModelCallEvidence) -> None: + """Verify response custody for the effect already selected by CHECKERS.""" + await self._require(attempt_id, "model_response", evidence) + + async def _require( + self, + attempt_id: UUID, + role: str, + evidence: ModelCallEvidence, + ) -> None: + """Match all identities and integrity states without acquiring another owner lock.""" + if not self._session.in_transaction() or self._session.in_nested_transaction(): + raise ModelCallUnavailable("model_call_evidence_unavailable") + evidence = ModelCallEvidence.model_validate(evidence) + binding = await self._session.scalar( + select(ArtifactBinding.id) + .join(ArtifactContent, ArtifactContent.id == ArtifactBinding.content_id) + .join(ArtifactPutAttempt, ArtifactPutAttempt.id == ArtifactBinding.put_attempt_id) + .join( + ArtifactVerificationReceipt, + ArtifactVerificationReceipt.id == ArtifactBinding.verification_receipt_id, + ) + .join( + ArtifactVerificationJob, + ArtifactVerificationJob.id == ArtifactVerificationReceipt.verification_job_id, + ) + .join(ArtifactReplica, ArtifactReplica.id == ArtifactVerificationJob.replica_id) + .where( + ArtifactBinding.id == str(evidence.binding_id), + ArtifactBinding.resource_type == "checker_model_call", + ArtifactBinding.resource_id == str(attempt_id), + ArtifactBinding.logical_role == role, + ArtifactBinding.scope_version == 1, + ArtifactBinding.attribution_type == "service_identity", + ArtifactBinding.actor_id == "workstream.artifact.checker_output", + ArtifactBinding.project_id == ArtifactPutAttempt.project_id, + ArtifactBinding.content_id == str(evidence.content_id), + ArtifactBinding.put_attempt_id == str(evidence.put_attempt_id), + ArtifactBinding.verification_receipt_id == str(evidence.verification_receipt_id), + ArtifactBinding.supersedes_binding_id.is_(None), + ArtifactContent.sha256 == evidence.content_sha256, + ArtifactContent.byte_count == evidence.byte_count, + ArtifactContent.media_type == evidence.media_type, + ArtifactPutAttempt.model_call_attempt_id == str(attempt_id), + ArtifactPutAttempt.checker_run_id.is_(None), + ArtifactPutAttempt.producer_request_type == "checker_output", + ArtifactPutAttempt.logical_role == role, + ArtifactPutAttempt.sha256 == evidence.content_sha256, + ArtifactPutAttempt.byte_count == evidence.byte_count, + ArtifactPutAttempt.media_type == evidence.media_type, + ArtifactPutAttempt.status == "object_confirmed", + ArtifactPutAttempt.checker_output_custody_sealed.is_(True), + ArtifactPutAttempt.replica_id == ArtifactReplica.id, + ArtifactPutAttempt.storage_namespace_id == ArtifactReplica.storage_namespace_id, + ArtifactPutAttempt.namespace_fingerprint == ArtifactReplica.namespace_fingerprint, + ArtifactPutAttempt.canonical_target == ArtifactReplica.provider_object_ref, + ArtifactVerificationJob.originating_put_attempt_id == ArtifactPutAttempt.id, + ArtifactVerificationJob.status == "verified", + ArtifactVerificationJob.terminal_result_code == "verified", + ArtifactVerificationJob.terminal_at.is_not(None), + ArtifactVerificationJob.checker_output_custody_sealed.is_(True), + ArtifactVerificationReceipt.outcome == "verified", + ArtifactVerificationReceipt.execution_generation + == ArtifactVerificationJob.execution_generation, + ArtifactVerificationReceipt.observed_sha256 == evidence.content_sha256, + ArtifactVerificationReceipt.observed_byte_count == evidence.byte_count, + ArtifactReplica.content_id == ArtifactContent.id, + ArtifactReplica.checker_output_custody_sealed.is_(True), + ArtifactReplica.verification_state == "verified", + ArtifactReplica.availability_state == "available", + ArtifactReplica.integrity_state == "valid", + ) + ) + if binding is None: + raise ModelCallUnavailable("model_call_evidence_unavailable") diff --git a/backend/app/modules/artifacts/checker_output_bindings.py b/backend/app/modules/artifacts/checker_output_bindings.py index 23285287b..35b08e5fc 100644 --- a/backend/app/modules/artifacts/checker_output_bindings.py +++ b/backend/app/modules/artifacts/checker_output_bindings.py @@ -41,27 +41,6 @@ def discard(self) -> None: """Release process-local authority without committing or rolling back.""" -class DenyCheckerOutputBindingAuthority: - """No production binding until exact AUTH and CHECKERS custody are implemented.""" - - async def preflight(self, selector: CheckerOutputSelector) -> None: - """Reject before protected reads.""" - raise CheckerOutputUnavailable("checker_output_binding_unavailable") - - async def prepare(self, selector: CheckerOutputSelector) -> None: - """Reject binding authority preparation.""" - raise CheckerOutputUnavailable("checker_output_binding_unavailable") - - async def consume( - self, reservation: CheckerOutputReservation, facts: CheckerOutputStoredFacts - ) -> None: - """Reject publication authority.""" - raise CheckerOutputUnavailable("checker_output_binding_unavailable") - - def discard(self) -> None: - """No prepared authority exists.""" - - class CheckerOutputBindingService: """Flush one immutable binding; never commit the caller's result transaction.""" @@ -97,7 +76,7 @@ async def bind_checker_output( await self._session.execute( text("select pg_advisory_xact_lock(hashtextextended(:key, 0))"), { - "key": f"checker-output:{selector.evaluation.project_id}:{selector.checker_run_id}:{selector.slot_key}", + "key": f"checker-output:{selector.paid_slot.project_id}:{selector.model_call_attempt_id}:{selector.slot_key}", }, ) facts = await select_checker_output( @@ -119,9 +98,9 @@ async def bind_checker_output( binding = await self._session.scalar( select(ArtifactBinding) .where( - ArtifactBinding.project_id == str(selector.evaluation.project_id), - ArtifactBinding.resource_type == "checker_run", - ArtifactBinding.resource_id == str(selector.checker_run_id), + ArtifactBinding.project_id == str(selector.paid_slot.project_id), + ArtifactBinding.resource_type == "checker_model_call", + ArtifactBinding.resource_id == str(selector.model_call_attempt_id), ArtifactBinding.logical_role == selector.slot_key, ArtifactBinding.scope_version == 1, ) @@ -148,9 +127,9 @@ async def bind_checker_output( binding = ArtifactBinding( id=str(new_record_id()), content_id=str(facts.content_id), - project_id=str(selector.evaluation.project_id), - resource_type="checker_run", - resource_id=str(selector.checker_run_id), + project_id=str(selector.paid_slot.project_id), + resource_type="checker_model_call", + resource_id=str(selector.model_call_attempt_id), logical_role=selector.slot_key, scope_version=1, actor_id=ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value, diff --git a/backend/app/modules/artifacts/checker_output_custody.py b/backend/app/modules/artifacts/checker_output_custody.py index beff8fdb2..7e2a00e3c 100644 --- a/backend/app/modules/artifacts/checker_output_custody.py +++ b/backend/app/modules/artifacts/checker_output_custody.py @@ -64,14 +64,14 @@ async def select_checker_output( select(ArtifactPutAttempt) .where( ArtifactPutAttempt.operation_identity == checker_output_operation_identity( - checker_run_id=selector.checker_run_id, slot_key=selector.slot_key + model_call_attempt_id=selector.model_call_attempt_id, slot_key=selector.slot_key ), ) .execution_options(populate_existing=True) ) if attempt is None: return None - evaluation = selector.evaluation + paid_slot = selector.paid_slot if ( ( attempt.producer_request_type, @@ -79,7 +79,7 @@ async def select_checker_output( attempt.task_id, attempt.submission_id, attempt.submission_version, - attempt.checker_run_id, + attempt.model_call_attempt_id, attempt.logical_role, attempt.checker_request_digest, attempt.media_type, @@ -87,17 +87,19 @@ async def select_checker_output( ) != ( "checker_output", - str(evaluation.project_id), - str(evaluation.task_id), - str(evaluation.submission_id), - evaluation.submission_version, - str(selector.checker_run_id), + str(paid_slot.project_id), + str(selector.task_id), + str(paid_slot.submission_id), + paid_slot.submission_version, + str(selector.model_call_attempt_id), slot.key, expected_digest, slot.media_type, namespace_fingerprint, ) or attempt.byte_count > slot.maximum_bytes + or attempt.checker_run_id is not None + or attempt.sha256 != selector.content_sha256 or (put_attempt_id is not None and attempt.id != str(put_attempt_id)) ): raise CheckerOutputUnavailable("checker_output_identity_mismatch") diff --git a/backend/app/modules/artifacts/checker_outputs.py b/backend/app/modules/artifacts/checker_outputs.py index c2a9ce0d7..69ab6895e 100644 --- a/backend/app/modules/artifacts/checker_outputs.py +++ b/backend/app/modules/artifacts/checker_outputs.py @@ -3,6 +3,7 @@ from collections.abc import Callable from typing import Protocol from uuid import UUID +import hashlib from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -16,12 +17,13 @@ CheckerOutputSelector, CheckerOutputUnavailable, ) -from app.interfaces.artifacts import ArtifactStore +from app.interfaces.artifacts import ArtifactStore, ArtifactByteRange, ArtifactIntegrityError from app.modules.artifacts.checker_output_custody import ( CheckerOutputStoredFacts, select_checker_output, ) -from app.modules.artifacts.models import ArtifactVerificationJob +from app.modules.artifacts.models import ArtifactVerificationJob, ArtifactReplica +from app.modules.checkers.api.model_calls import ModelCallEvidence from app.modules.artifacts.preparation import ArtifactPreparationService from app.modules.artifacts.schemas import ( ArtifactInternalAuthority, @@ -55,31 +57,6 @@ def discard(self) -> None: """Dispose transaction-local authority after each phase.""" -class DenyCheckerOutputWriteAuthority: - """Keep production output storage unavailable until ARCH-04D activation.""" - - async def preflight(self, selector: CheckerOutputSelector) -> None: - """Reject before any protected access.""" - raise CheckerOutputUnavailable("checker_output_write_unavailable") - - async def prepare(self, selector: CheckerOutputSelector) -> None: - """Reject transaction authority preparation.""" - raise CheckerOutputUnavailable("checker_output_write_unavailable") - - async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: - """Reject durable output admission.""" - raise CheckerOutputUnavailable("checker_output_write_unavailable") - - async def consume_existing( - self, reservation: CheckerOutputReservation, facts: CheckerOutputStoredFacts - ) -> None: - """Reject recovery of protected output facts.""" - raise CheckerOutputUnavailable("checker_output_write_unavailable") - - def discard(self) -> None: - """No authority was retained.""" - - class CheckerArtifactOutputService: """Own preparation lifetime; reuse put/observation and independent verification.""" @@ -90,6 +67,7 @@ def __init__( reservations: Callable[[AsyncSession], CheckerOutputReservationPort], authority: Callable[[AsyncSession], CheckerOutputWriteAuthority], internal_authority: Callable[[AsyncSession], ArtifactInternalAuthority], + verification_authority: Callable[[AsyncSession], ArtifactInternalAuthority], preparation: ArtifactPreparationService, store: ArtifactStore, namespace: ArtifactStorageNamespaceSpec, @@ -97,6 +75,7 @@ def __init__( ) -> None: self._sessions, self._reservations, self._authority = sessions, reservations, authority self._internal_authority, self._preparation = internal_authority, preparation + self._verification_authority = verification_authority self._store, self._namespace, self._settings = store, namespace, settings async def store(self, request: CheckerOutputArtifactRequest) -> CheckerOutputArtifactResult: @@ -186,6 +165,63 @@ async def recover(self, selector: CheckerOutputSelector) -> CheckerOutputArtifac finally: authority.discard() + async def read_response( + self, selector: CheckerOutputSelector, evidence: ModelCallEvidence, + ) -> bytes: + """Read only exact private response evidence, bounded and independently hashed.""" + selector = CheckerOutputSelector.model_validate(selector) + evidence = ModelCallEvidence.model_validate(evidence) + if selector.slot_key != "model_response" or selector.content_sha256 != evidence.content_sha256: + raise CheckerOutputUnavailable("checker_output_response_unavailable") + async with self._sessions() as session: + authority = self._authority(session) + try: + await authority.preflight(selector) + async with session.begin(): + await authority.prepare(selector) + reservation = await self._reservations(session).resolve(selector) + facts = await select_checker_output( + session, selector=selector, reservation=reservation, + namespace_fingerprint=self._namespace.namespace_fingerprint, + put_attempt_id=evidence.put_attempt_id, + verification_receipt_id=evidence.verification_receipt_id, + ) + if facts is None or facts.status != "verified" or ( + facts.content_id, facts.sha256, facts.byte_count, facts.media_type, + ) != ( + evidence.content_id, evidence.content_sha256, + evidence.byte_count, evidence.media_type, + ): + raise CheckerOutputUnavailable("checker_output_response_unavailable") + await authority.consume_existing(reservation, facts) + provider_ref = await session.scalar(select(ArtifactReplica.provider_object_ref).where( + ArtifactReplica.id == str(facts.replica_id), + ArtifactReplica.namespace_fingerprint == self._namespace.namespace_fingerprint, + )) + if provider_ref is None: + raise CheckerOutputUnavailable("checker_output_response_unavailable") + authority.discard() + data = bytearray() + async for chunk in self._store.open( + provider_ref, byte_range=ArtifactByteRange(length=evidence.byte_count + 1), + ): + data.extend(chunk) + if len(data) > evidence.byte_count: + raise ArtifactIntegrityError("model response evidence length mismatch") + if len(data) != evidence.byte_count or ( + "sha256:" + hashlib.sha256(data).hexdigest() != evidence.content_sha256 + ): + raise ArtifactIntegrityError("model response evidence digest mismatch") + # Revocation and owner substitution during provider I/O still deny release. + current = await self._result(session, authority, selector, replayed=True) + if (current.content_id, current.verification_receipt_id) != ( + evidence.content_id, evidence.verification_receipt_id, + ): + raise CheckerOutputUnavailable("checker_output_response_unavailable") + return bytes(data) + finally: + authority.discard() + def _storage(self, session: AsyncSession) -> ArtifactStorageOrchestrator: """Use the shared put, observation and verification implementation.""" return ArtifactStorageOrchestrator( @@ -218,7 +254,11 @@ async def _verify( .limit(1) ) if job_id is not None: - await storage.verify_object(UUID(job_id)) + verifier = ArtifactStorageOrchestrator( + session, self._store, self._namespace, self._settings, + self._verification_authority(session), + ) + await verifier.verify_object(UUID(job_id)) async def _result( self, diff --git a/backend/app/modules/artifacts/models.py b/backend/app/modules/artifacts/models.py index b625c355c..954a0fcd4 100644 --- a/backend/app/modules/artifacts/models.py +++ b/backend/app/modules/artifacts/models.py @@ -424,9 +424,9 @@ class ArtifactBinding(Base): name="scope_version_predecessor", ), CheckConstraint( - "(resource_type = 'checker_run' and scope_version = 1 " + "(resource_type in ('checker_run','checker_model_call') and scope_version = 1 " "and put_attempt_id is not null and verification_receipt_id is not null) or " - "(resource_type <> 'checker_run' and put_attempt_id is null " + "(resource_type not in ('checker_run','checker_model_call') and put_attempt_id is null " "and verification_receipt_id is null)", name="checker_output_lineage", ), @@ -1405,19 +1405,21 @@ class ArtifactPutAttempt(Base): ), CheckConstraint( "(producer_request_type = 'guide' and task_import_source_id is null and guide_source_item_id is not null " - "and checker_run_id is null and task_id is null and submission_id is null " + "and checker_run_id is null and model_call_attempt_id is null and task_id is null and submission_id is null " "and submission_version is null " "and logical_role is null) or " "(producer_request_type = 'checker_output' and task_import_source_id is null and guide_source_item_id is null " - "and checker_run_id is not null and task_id is not null " + "and ((checker_run_id is not null and model_call_attempt_id is null) or " + "(checker_run_id is null and model_call_attempt_id is not null " + "and logical_role in ('model_request','model_response'))) and task_id is not null " "and submission_id is not null and submission_version is not null " "and octet_length(logical_role) between 1 and 100) or " "(producer_request_type = 'submission_bundle' and task_import_source_id is null " - "and guide_source_item_id is null and checker_run_id is null " + "and guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null " "and task_id is not null and submission_id is null " "and submission_version is null and logical_role is null) or " "(producer_request_type = 'task_import_source' and task_import_source_id is not null " - "and guide_source_item_id is null and checker_run_id is null and task_id is null " + "and guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null and task_id is null " "and submission_id is null and submission_version is null " "and logical_role is not distinct from 'task_import_source')", name="producer_reference", @@ -1441,6 +1443,9 @@ class ArtifactPutAttempt(Base): checker_run_id: Mapped[str | None] = mapped_column( ForeignKey("checker_runs.id", ondelete="RESTRICT"), index=True ) + model_call_attempt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("checker_model_call_attempts.id", ondelete="RESTRICT"), index=True + ) submission_id: Mapped[str | None] = mapped_column(Uuid(as_uuid=False), index=True) submission_version: Mapped[int | None] = mapped_column(Integer) logical_role: Mapped[str | None] = mapped_column(String(100)) @@ -1849,12 +1854,14 @@ class ArtifactOperationReceipt(Base): CheckConstraint("attempt_number > 0", name="attempt_positive"), CheckConstraint( "contract_version = 2 and put_attempt_id is not null and " - "((guide_source_item_id is not null and checker_run_id is null " + "((guide_source_item_id is not null and checker_run_id is null and model_call_attempt_id is null " "and logical_role is null) or " - "(guide_source_item_id is null and checker_run_id is not null " + "(guide_source_item_id is null and checker_run_id is not null and model_call_attempt_id is null " "and octet_length(logical_role) between 1 and 100) or " - "(guide_source_item_id is null and checker_run_id is null " - "and (logical_role is null or logical_role='task_import_source')))", + "(guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null " + "and (logical_role is null or logical_role='task_import_source')) or " + "(guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is not null " + "and logical_role in ('model_request','model_response')))", name="contract_producer_reference", ), ) @@ -1870,6 +1877,9 @@ class ArtifactOperationReceipt(Base): checker_run_id: Mapped[str | None] = mapped_column( ForeignKey("checker_runs.id", ondelete="RESTRICT"), index=True ) + model_call_attempt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("checker_model_call_attempts.id", ondelete="RESTRICT"), index=True + ) logical_role: Mapped[str | None] = mapped_column(String(100)) replica_id: Mapped[str] = mapped_column( ForeignKey("artifact_replicas.id", ondelete="RESTRICT"), diff --git a/backend/app/modules/artifacts/schemas.py b/backend/app/modules/artifacts/schemas.py index c064f4fef..83a35d0db 100644 --- a/backend/app/modules/artifacts/schemas.py +++ b/backend/app/modules/artifacts/schemas.py @@ -48,19 +48,19 @@ class GuideArtifactAdmissionRequest: @final @dataclass(frozen=True, slots=True) class CheckerOutputArtifactAdmissionRequest: - """One prepared checker output admitted under its exact checker run.""" + """One prepared model transcript admitted under its exact durable call effect.""" reservation: CheckerOutputReservation slot_key: str source: CommittedArtifactSource -def checker_output_operation_identity(*, checker_run_id: UUID, slot_key: str) -> str: - """Keep one logical output identity across worker-lease replacement.""" +def checker_output_operation_identity(*, model_call_attempt_id: UUID, slot_key: str) -> str: + """Keep one transcript identity across checker generations and lease changes.""" return canonical_json_hash( { "request_type": "checker_output", - "checker_run_id": str(checker_run_id), + "model_call_attempt_id": str(model_call_attempt_id), "logical_role": slot_key, } ) @@ -72,16 +72,13 @@ def checker_output_request_digest_facts( slot: CheckerOutputSlot, ) -> dict[str, object]: """Return stable owner facts shared by checker admission and binding.""" - evaluation = reservation.evaluation + selector = reservation.selector return { - "evaluation_request_id": str(evaluation.evaluation_request_id), - "evaluation_request_sha256": evaluation.request_sha256, - "evaluation_generation": evaluation.evaluation_generation, - "checker_run_id": str(reservation.checker_run_id), - "project_id": str(evaluation.project_id), - "task_id": str(evaluation.task_id), - "submission_id": str(evaluation.submission_id), - "submission_version": evaluation.submission_version, + "model_call_slot_id": str(selector.model_call_slot_id), + "model_call_attempt_id": str(selector.model_call_attempt_id), + "paid_slot": selector.paid_slot.model_dump(mode="json"), + "task_id": str(selector.task_id), + "content_sha256": selector.content_sha256, "slot_key": slot.key, "media_type": slot.media_type, "maximum_bytes": slot.maximum_bytes, diff --git a/backend/app/modules/artifacts/service.py b/backend/app/modules/artifacts/service.py index ab38b7a86..f91045433 100644 --- a/backend/app/modules/artifacts/service.py +++ b/backend/app/modules/artifacts/service.py @@ -202,6 +202,7 @@ class _AdmissionFacts: operation_identity: str task_import_source_id: str | None = None selected_pre_submit_run_id: str | None = None + model_call_attempt_id: str | None = None def artifact_storage_namespace_spec( @@ -1065,6 +1066,7 @@ async def _complete_transaction_b( put_attempt_id=attempt.id, guide_source_item_id=attempt.guide_source_item_id, checker_run_id=attempt.checker_run_id, + model_call_attempt_id=attempt.model_call_attempt_id, logical_role=attempt.logical_role, replica_id=replica.id, operation="put", @@ -2408,6 +2410,7 @@ async def admit( "submission_id": facts.submission_id, "submission_version": facts.submission_version, "checker_output_request": facts.checker_request_digest_facts, + "model_call_attempt_id": facts.model_call_attempt_id, } if facts.request_type == "checker_output" else {} @@ -2476,6 +2479,7 @@ async def admit( task_import_source_id=facts.task_import_source_id, guide_source_item_id=facts.guide_source_item_id, checker_run_id=facts.checker_run_id, + model_call_attempt_id=facts.model_call_attempt_id, logical_role=facts.logical_role, submission_id=facts.submission_id, submission_version=facts.submission_version, @@ -2560,15 +2564,9 @@ def _validate_request_boundary(request: ArtifactAdmissionRequest) -> None: if type(request.reservation) is not CheckerOutputReservation: raise TypeError("invalid checker output reservation") try: - request.reservation.select( - CheckerOutputSelector( - evaluation=request.reservation.evaluation, - checker_run_id=request.reservation.checker_run_id, - worker_lease_id=request.reservation.worker_lease_id, - worker_lease_generation=request.reservation.worker_lease_generation, - slot_key=request.slot_key, - ) - ) + request.reservation.select(request.reservation.selector) + if request.slot_key != request.reservation.selector.slot_key: + raise CheckerOutputUnavailable("checker_output_slot_unavailable") except (CheckerOutputUnavailable, ValueError) as exc: raise ArtifactAdmissionRelationshipError( "checker output reservation is unavailable" @@ -2635,18 +2633,13 @@ async def _guide_facts(self, request: GuideArtifactAdmissionRequest) -> _Admissi async def _checker_output_facts( self, request: CheckerOutputArtifactAdmissionRequest ) -> _AdmissionFacts: - """Bind committed bytes to one owner-issued run slot and fixed service.""" + """Bind committed transcript bytes to one durable call effect and role.""" reservation = CheckerOutputReservation.model_validate(request.reservation) + selector = reservation.selector try: - slot = reservation.select( - CheckerOutputSelector( - evaluation=reservation.evaluation, - checker_run_id=reservation.checker_run_id, - worker_lease_id=reservation.worker_lease_id, - worker_lease_generation=reservation.worker_lease_generation, - slot_key=request.slot_key, - ) - ) + slot = reservation.select(selector) + if slot.key != request.slot_key: + raise CheckerOutputUnavailable("checker_output_slot_unavailable") except (CheckerOutputUnavailable, ValueError) as exc: raise ArtifactAdmissionRelationshipError( "checker output reservation is unavailable" @@ -2655,15 +2648,15 @@ async def _checker_output_facts( if ( commitment.media_type != slot.media_type or commitment.byte_count > slot.maximum_bytes + or commitment.sha256 != selector.content_sha256 ): raise ArtifactAdmissionRelationshipError( "checker output does not match reserved slot" ) - evaluation = reservation.evaluation - checker_run_id = str(reservation.checker_run_id) + paid_slot = selector.paid_slot logical_role = slot.key operation_identity = checker_output_operation_identity( - checker_run_id=reservation.checker_run_id, slot_key=logical_role + model_call_attempt_id=selector.model_call_attempt_id, slot_key=logical_role ) digest_facts = checker_output_request_digest_facts( reservation=reservation, @@ -2673,15 +2666,16 @@ async def _checker_output_facts( request_type="checker_output", producer_type="service_identity", producer_ref=ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value, - project_id=str(evaluation.project_id), + project_id=str(paid_slot.project_id), guide_id=None, - task_id=str(evaluation.task_id), + task_id=str(selector.task_id), guide_source_item_id=None, guide_source_snapshot_id=None, - checker_run_id=checker_run_id, + checker_run_id=None, + model_call_attempt_id=str(selector.model_call_attempt_id), logical_role=logical_role, - submission_id=str(evaluation.submission_id), - submission_version=evaluation.submission_version, + submission_id=str(paid_slot.submission_id), + submission_version=paid_slot.submission_version, checker_request_digest=canonical_json_hash(digest_facts), checker_request_digest_facts=digest_facts, pre_submit_evidence_set_id=None, diff --git a/backend/app/modules/authorization/admin_schemas.py b/backend/app/modules/authorization/admin_schemas.py index 19033d461..9d9a6159d 100644 --- a/backend/app/modules/authorization/admin_schemas.py +++ b/backend/app/modules/authorization/admin_schemas.py @@ -35,7 +35,7 @@ class PermissionDefinitionsResponse(BaseModel): model_config = _STRICT items: tuple[PermissionDefinitionResponse, ...] - total: Literal[82] + total: Literal[91] class AdminRoleDefinitionResponse(BaseModel): diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 576c31955..fc68ac0f8 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -30,6 +30,17 @@ class PermissionId(StrEnum): CHECKER_PRE_SUBMIT_FINALIZE = "checker.pre_submit.finalize" CHECKER_POST_SUBMIT_EXECUTE = "checker.post_submit.execute" CHECKER_POST_SUBMIT_FINALIZE = "checker.post_submit.finalize" + CHECKER_MODEL_CALL_PREPARE = "checker.model_call.prepare" + CHECKER_MODEL_CALL_DISPATCH = "checker.model_call.dispatch" + CHECKER_MODEL_CALL_RECOVER = "checker.model_call.recover" + CHECKER_MODEL_CALL_OBSERVE = "checker.model_call.observe" + CHECKER_MODEL_CALL_COMPLETE = "checker.model_call.complete" + CHECKER_MODEL_CALL_GRANT = "checker.model_call.grant" + PROJECT_MODEL_PROFILE_MANAGE = "project.model_profile.manage" + PROJECT_MODEL_BUDGET_MANAGE = "project.model_budget.manage" + PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE = ( + "project.model_call.recovery_child.authorize" + ) OUTBOX_DISPATCH = "outbox.dispatch" ACTOR_SERVICE_PROVISION = "actor.service.provision" ADMIN_ROLE_READ = "admin_role.read" @@ -156,6 +167,17 @@ class ActionId(StrEnum): CHECKER_PRE_SUBMIT_FINALIZE = "checker.pre_submit.finalize" CHECKER_POST_SUBMIT_EXECUTE = "checker.post_submit.execute" CHECKER_POST_SUBMIT_FINALIZE = "checker.post_submit.finalize" + CHECKER_MODEL_CALL_PREPARE = "checker.model_call.prepare" + CHECKER_MODEL_CALL_DISPATCH = "checker.model_call.dispatch" + CHECKER_MODEL_CALL_RECOVER = "checker.model_call.recover" + CHECKER_MODEL_CALL_OBSERVE = "checker.model_call.observe" + CHECKER_MODEL_CALL_COMPLETE = "checker.model_call.complete" + CHECKER_MODEL_CALL_GRANT = "checker.model_call.grant" + PROJECT_MODEL_PROFILE_PROVISION = "project.model_profile.provision" + PROJECT_MODEL_BUDGET_CONFIGURE = "project.model_budget.configure" + PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE = ( + "project.model_call.recovery_child.authorize" + ) OUTBOX_DISPATCH = "outbox.dispatch" ACTOR_SERVICE_PROVISION = "actor.service.provision" PROJECT_CONTRIBUTOR_CANDIDATE_LIST = "project.contributor_candidate.list" @@ -339,6 +361,7 @@ class ActionOwner(StrEnum): PILOT_02A = "PILOT-02A" PILOT_02B = "PILOT-02B" PILOT_04 = "PILOT-04" + PILOT_05 = "PILOT-05" PILOT_08 = "PILOT-08" PILOT_10 = "PILOT-10" PILOT_READ_2 = "PILOT-READ-2" @@ -712,7 +735,7 @@ def _active( _active(ActionId.ARTIFACT_GUIDE_SOURCE_READ, PermissionId.ARTIFACT_GUIDE_SOURCE_READ, ActionOwner.XINT_002_04B), _active(ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, PermissionId.SUBMISSION_CREATE, ActionOwner.XINT_002_05A), _active(ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.AUTH_ART_05), - _planned( + _active( ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.AUTH_ART_06B, @@ -736,6 +759,51 @@ def _active( _active(ActionId.CHECKER_PRE_SUBMIT_FINALIZE, PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, ActionOwner.PILOT_04), _active(ActionId.CHECKER_POST_SUBMIT_EXECUTE, PermissionId.CHECKER_POST_SUBMIT_EXECUTE, ActionOwner.ARCH_04D2), _active(ActionId.CHECKER_POST_SUBMIT_FINALIZE, PermissionId.CHECKER_POST_SUBMIT_FINALIZE, ActionOwner.ARCH_04D2), + _active( + ActionId.CHECKER_MODEL_CALL_PREPARE, + PermissionId.CHECKER_MODEL_CALL_PREPARE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_DISPATCH, + PermissionId.CHECKER_MODEL_CALL_DISPATCH, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_RECOVER, + PermissionId.CHECKER_MODEL_CALL_RECOVER, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_OBSERVE, + PermissionId.CHECKER_MODEL_CALL_OBSERVE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_COMPLETE, + PermissionId.CHECKER_MODEL_CALL_COMPLETE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_GRANT, + PermissionId.CHECKER_MODEL_CALL_GRANT, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_MODEL_PROFILE_PROVISION, + PermissionId.PROJECT_MODEL_PROFILE_MANAGE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_MODEL_BUDGET_CONFIGURE, + PermissionId.PROJECT_MODEL_BUDGET_MANAGE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, + PermissionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, + ActionOwner.PILOT_05, + ), _active( ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, @@ -746,7 +814,7 @@ def _active( PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, ActionOwner.AUTH_ART_06A, ), - _planned( + _active( ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, ActionOwner.AUTH_ART_06B, @@ -793,6 +861,15 @@ def _active( { PermissionId.CHECKER_POST_SUBMIT_EXECUTE, PermissionId.CHECKER_POST_SUBMIT_FINALIZE, + PermissionId.CHECKER_MODEL_CALL_PREPARE, + PermissionId.CHECKER_MODEL_CALL_DISPATCH, + PermissionId.CHECKER_MODEL_CALL_RECOVER, + PermissionId.CHECKER_MODEL_CALL_OBSERVE, + PermissionId.CHECKER_MODEL_CALL_COMPLETE, + PermissionId.CHECKER_MODEL_CALL_GRANT, + PermissionId.PROJECT_MODEL_PROFILE_MANAGE, + PermissionId.PROJECT_MODEL_BUDGET_MANAGE, + PermissionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, @@ -830,9 +907,9 @@ def _active( def _require_catalogue_counts() -> None: """Keep the closed action inventory and permission boundary exact.""" - if len(PERMISSION_IDS) != 82 or len(ACTION_IDS) != 177: + if len(PERMISSION_IDS) != 91 or len(ACTION_IDS) != 186: raise RuntimeError("authorization catalogue count mismatch") - if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 33: + if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 42: raise RuntimeError("authorization permission boundary mismatch") @@ -945,12 +1022,23 @@ def _validate_action_definitions(definitions: tuple[ActionDefinition, ...]) -> N ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, ActionId.SUBMISSION_CREATE, ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, ActionId.CHECKER_POST_SUBMIT_EXECUTE, ActionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionId.CHECKER_MODEL_CALL_PREPARE, + ActionId.CHECKER_MODEL_CALL_DISPATCH, + ActionId.CHECKER_MODEL_CALL_RECOVER, + ActionId.CHECKER_MODEL_CALL_OBSERVE, + ActionId.CHECKER_MODEL_CALL_COMPLETE, + ActionId.CHECKER_MODEL_CALL_GRANT, + ActionId.PROJECT_MODEL_PROFILE_PROVISION, + ActionId.PROJECT_MODEL_BUDGET_CONFIGURE, + ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, ActionId.CHECKER_PRE_SUBMIT_EXECUTE, ActionId.CHECKER_PRE_SUBMIT_FINALIZE, ActionId.COMPENSATION_ADAPTER_BINDING_READ, ActionId.COMPENSATION_ADAPTER_BINDING_CREATE, @@ -996,7 +1084,18 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp _SERVICE_ACTIONS = { ServiceIdentity.TASK_POST_SUBMIT_ROUTER: frozenset({ActionId.TASK_POST_SUBMIT_ROUTE}), ServiceIdentity.CHECKER_PRE_SUBMIT: frozenset({ActionId.CHECKER_PRE_SUBMIT_EXECUTE, ActionId.CHECKER_PRE_SUBMIT_FINALIZE}), - ServiceIdentity.CHECKER_POST_SUBMIT: frozenset({ActionId.CHECKER_POST_SUBMIT_EXECUTE, ActionId.CHECKER_POST_SUBMIT_FINALIZE}), + ServiceIdentity.CHECKER_POST_SUBMIT: frozenset( + { + ActionId.CHECKER_POST_SUBMIT_EXECUTE, + ActionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionId.CHECKER_MODEL_CALL_PREPARE, + ActionId.CHECKER_MODEL_CALL_DISPATCH, + ActionId.CHECKER_MODEL_CALL_RECOVER, + ActionId.CHECKER_MODEL_CALL_OBSERVE, + ActionId.CHECKER_MODEL_CALL_COMPLETE, + ActionId.CHECKER_MODEL_CALL_GRANT, + } + ), ServiceIdentity.TASK_ASSIGNMENT_RECONCILER: frozenset({ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE}), ServiceIdentity.TASK_REVISION_EXPIRER: frozenset({ ActionId.TASK_REVISION_EXPIRE, ActionId.TASK_REVISION_RECOVER, @@ -1055,6 +1154,12 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp (ServiceIdentity.CHECKER_PRE_SUBMIT, ActionId.CHECKER_PRE_SUBMIT_FINALIZE), (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_POST_SUBMIT_EXECUTE), (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_POST_SUBMIT_FINALIZE), + (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_MODEL_CALL_PREPARE), + (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_MODEL_CALL_DISPATCH), + (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_MODEL_CALL_RECOVER), + (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_MODEL_CALL_OBSERVE), + (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_MODEL_CALL_COMPLETE), + (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_MODEL_CALL_GRANT), (ServiceIdentity.TASK_ASSIGNMENT_RECONCILER, ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE), (ServiceIdentity.TASK_REVISION_EXPIRER, ActionId.TASK_REVISION_EXPIRE), (ServiceIdentity.TASK_REVISION_EXPIRER, ActionId.TASK_REVISION_RECOVER), @@ -1104,6 +1209,12 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp ActionId.TASK_POST_SUBMIT_ROUTE, ActionId.CHECKER_POST_SUBMIT_EXECUTE, ActionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionId.CHECKER_MODEL_CALL_PREPARE, + ActionId.CHECKER_MODEL_CALL_DISPATCH, + ActionId.CHECKER_MODEL_CALL_RECOVER, + ActionId.CHECKER_MODEL_CALL_OBSERVE, + ActionId.CHECKER_MODEL_CALL_COMPLETE, + ActionId.CHECKER_MODEL_CALL_GRANT, ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, ActionId.TASK_REVISION_EXPIRE, @@ -1115,8 +1226,10 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, ActionId.ARTIFACT_GUIDE_SOURCE_READ, ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, @@ -1133,6 +1246,30 @@ def _index_service_actions( ActionId.CHECKER_PRE_SUBMIT_FINALIZE: (PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, ActionOwner.PILOT_04), ActionId.CHECKER_POST_SUBMIT_EXECUTE: (PermissionId.CHECKER_POST_SUBMIT_EXECUTE, ActionOwner.ARCH_04D2), ActionId.CHECKER_POST_SUBMIT_FINALIZE: (PermissionId.CHECKER_POST_SUBMIT_FINALIZE, ActionOwner.ARCH_04D2), + ActionId.CHECKER_MODEL_CALL_PREPARE: ( + PermissionId.CHECKER_MODEL_CALL_PREPARE, + ActionOwner.PILOT_05, + ), + ActionId.CHECKER_MODEL_CALL_DISPATCH: ( + PermissionId.CHECKER_MODEL_CALL_DISPATCH, + ActionOwner.PILOT_05, + ), + ActionId.CHECKER_MODEL_CALL_RECOVER: ( + PermissionId.CHECKER_MODEL_CALL_RECOVER, + ActionOwner.PILOT_05, + ), + ActionId.CHECKER_MODEL_CALL_OBSERVE: ( + PermissionId.CHECKER_MODEL_CALL_OBSERVE, + ActionOwner.PILOT_05, + ), + ActionId.CHECKER_MODEL_CALL_COMPLETE: ( + PermissionId.CHECKER_MODEL_CALL_COMPLETE, + ActionOwner.PILOT_05, + ), + ActionId.CHECKER_MODEL_CALL_GRANT: ( + PermissionId.CHECKER_MODEL_CALL_GRANT, + ActionOwner.PILOT_05, + ), ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE: (PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, ActionOwner.ARCH_03C1), ActionId.TASK_REVISION_EXPIRE: (PermissionId.TASK_REVISION_EXPIRE, ActionOwner.PILOT_08), ActionId.TASK_REVISION_RECOVER: (PermissionId.TASK_REVISION_RECOVER, ActionOwner.PILOT_08), diff --git a/backend/app/modules/authorization/checker_model_authorization.py b/backend/app/modules/authorization/checker_model_authorization.py new file mode 100644 index 000000000..0bc999b87 --- /dev/null +++ b/backend/app/modules/authorization/checker_model_authorization.py @@ -0,0 +1,348 @@ +"""Prepared AUTH adapter for project-owned checker model calls.""" + +from __future__ import annotations + +from uuid import UUID + +from app.modules.actors.api import ServiceIdentity +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.checker_models import ( + BUDGET_CONFIGURE, + COMPLETE, + DISPATCH, + GRANT, + OBSERVE, + PREPARE, + PROFILE_PROVISION, + RECOVER, + RECOVERY_CHILD_AUTHORIZE, + CHECKER_MODEL_CALL_ACTIONS, + MODEL_ADMIN_ACTIONS, + ModelCallCompleteAuthorityFacts, + ModelCallDispatchAuthorityFacts, + ModelCallGrantAuthorityFacts, + ModelCallObserveAuthorityFacts, + ModelCallPrepareAuthorityFacts, + ModelCallRecoverAuthorityFacts, + ModelCallRecoveryChildAuthorityFacts, + checker_model_call_resource, + project_model_admin_resource, +) +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.prepared import PreparedAuthorizationService +from app.modules.authorization.runtime import ( + AuthorizationDenied, + AuthorizationEvidenceUnavailable, + HumanAuthorizationContext, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationInput, + PreparedAuthorizationUnsupported, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, + ServiceAuthorizationContext, +) +from app.modules.checkers.api.model_calls import ( + ModelActionEvidence, + ModelBudgetProvisioningFacts, + ModelCallEffectAuthorityFacts, + ModelCallEvidence, + ModelCallExecutionFacts, + ModelCallUnavailable, + ModelPaidSlotKey, + ModelProfileProvisioningFacts, + PreparedModelCallAttempt, + model_action_digest, +) + + +class CheckerModelAuthorization: + """Bind exact model-call facts to current human or fixed-service authority.""" + + __slots__ = ("_authorization", "_prepared") + + def __init__( + self, + authorization: AuthorizationService, + prepared: PreparedAuthorizationService, + ) -> None: + if ( + prepared._authorization is not authorization + or prepared._context != authorization._context + ): + raise TypeError("checker model authority requires one composition") + self._authorization = authorization + self._prepared = prepared + + async def authorize_profile(self, facts: ModelProfileProvisioningFacts) -> ModelActionEvidence: + """Require a current system Operator for one exact project profile.""" + facts = ModelProfileProvisioningFacts.model_validate(facts) + self._require_human(facts.actor_profile_id) + resource = project_model_admin_resource( + PROFILE_PROVISION, + facts, + actor_profile_id=facts.actor_profile_id, + ) + return await self._authorize( + PROFILE_PROVISION, + resource, + facts.operation_id, + model_action_digest("profile", facts), + ) + + async def authorize_budget(self, facts: ModelBudgetProvisioningFacts) -> ModelActionEvidence: + """Require a current system Operator for one exact project budget.""" + facts = ModelBudgetProvisioningFacts.model_validate(facts) + self._require_human(facts.actor_profile_id) + resource = project_model_admin_resource( + BUDGET_CONFIGURE, + facts, + actor_profile_id=facts.actor_profile_id, + ) + return await self._authorize( + BUDGET_CONFIGURE, + resource, + facts.operation_id, + model_action_digest("budget", facts), + ) + + async def authorize_prepare( + self, + execution: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + request_sha256: str, + ) -> ModelActionEvidence: + """Authorize one exact stable-slot preparation before CHECKERS locks.""" + facts = ModelCallPrepareAuthorityFacts( + execution=execution, + slot=slot, + request_sha256=request_sha256, + ) + return await self._authorize_runtime( + PREPARE, + facts, + execution.evaluation_request_id, + "prepare", + ) + + async def authorize_dispatch( + self, + execution: ModelCallExecutionFacts, + attempt: PreparedModelCallAttempt, + request_evidence: ModelCallEvidence, + ) -> ModelActionEvidence: + """Authorize reservation and one dispatch for exact verified evidence.""" + facts = ModelCallDispatchAuthorityFacts( + execution=execution, + attempt=attempt, + request_evidence=request_evidence, + ) + return await self._authorize_runtime( + DISPATCH, + facts, + attempt.internal_call_request_id, + "dispatch", + ) + + async def authorize_recover( + self, + execution: ModelCallExecutionFacts, + effect: ModelCallEffectAuthorityFacts, + ) -> ModelActionEvidence: + """Authorize current-run attachment to one existing paid effect.""" + facts = ModelCallRecoverAuthorityFacts( + execution=execution, + effect=effect, + ) + return await self._authorize_runtime( + RECOVER, + facts, + execution.evaluation_request_id, + "recover", + ) + + async def authorize_grant( + self, + execution: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + external_request_digest: str, + token_hash: str, + ) -> ModelActionEvidence: + """Authorize one opaque invocation grant for the exact current call slot.""" + facts = ModelCallGrantAuthorityFacts( + execution=execution, + slot=slot, + external_request_digest=external_request_digest, + token_hash=token_hash, + ) + return await self._authorize_runtime( + GRANT, + facts, + execution.evaluation_request_id, + "grant", + ) + + async def authorize_observe( + self, + effect: ModelCallEffectAuthorityFacts, + provider_response_id: str | None, + observation_digest: str, + ) -> ModelActionEvidence: + """Authorize a lease-independent observation of one dispatched effect.""" + facts = ModelCallObserveAuthorityFacts( + effect=effect, + provider_response_id=provider_response_id, + observation_digest=observation_digest, + ) + return await self._authorize_runtime( + OBSERVE, + facts, + effect.internal_call_request_id, + "observe", + ) + + async def authorize_child( + self, + execution: ModelCallExecutionFacts, + parent_attempt_id: UUID, + terminal_digest: str, + ) -> ModelActionEvidence: + """Require explicit system Operator authority for one recovery child.""" + actor_profile_id = self._require_human() + facts = ModelCallRecoveryChildAuthorityFacts( + execution=execution, + parent_attempt_id=parent_attempt_id, + terminal_digest=terminal_digest, + ) + resource = project_model_admin_resource( + RECOVERY_CHILD_AUTHORIZE, + facts, + actor_profile_id=actor_profile_id, + ) + return await self._authorize( + RECOVERY_CHILD_AUTHORIZE, + resource, + parent_attempt_id, + model_action_digest("child", facts), + ) + + async def authorize_complete( + self, + effect: ModelCallEffectAuthorityFacts, + response_evidence: ModelCallEvidence | None, + terminal_digest: str, + ) -> ModelActionEvidence: + """Authorize terminalization of one exact lease-independent effect.""" + facts = ModelCallCompleteAuthorityFacts( + effect=effect, + response_evidence=response_evidence, + terminal_digest=terminal_digest, + ) + return await self._authorize_runtime( + COMPLETE, + facts, + effect.internal_call_request_id, + "complete", + ) + + async def _authorize_runtime( + self, + action: ActionId, + facts: ( + ModelCallPrepareAuthorityFacts + | ModelCallDispatchAuthorityFacts + | ModelCallRecoverAuthorityFacts + | ModelCallObserveAuthorityFacts + | ModelCallCompleteAuthorityFacts + | ModelCallGrantAuthorityFacts + ), + idempotency_key: UUID, + service_action: str, + ) -> ModelActionEvidence: + self._require_post_submit_service() + resource = checker_model_call_resource(action, facts) + return await self._authorize( + action, + resource, + idempotency_key, + model_action_digest(service_action, facts), + ) + + async def _authorize( + self, + action: ActionId, + resource, + idempotency_key: UUID, + service_facts_digest: str, + ) -> ModelActionEvidence: + if action in CHECKER_MODEL_CALL_ACTIONS: + scope = PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=resource.scope_project_id, + ) + elif action in MODEL_ADMIN_ACTIONS: + scope = PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM) + else: + raise ModelCallUnavailable("model_call_authority_unavailable") + caller_input = PreparedAuthorizationInput( + idempotency_key=idempotency_key, + request_value=resource.model_dump(mode="json"), + ) + handle = None + try: + handle = await self._prepared.prepare(action, caller_input, scope) + decision = await self._prepared.consume( + handle, + action, + caller_input, + resource, + ) + except PreparedAuthorizationUnsupported as error: + try: + await self._prepared.deny_unsupported( + action, + caller_input, + resource, + error, + ) + except ( + AuthorizationDenied, + AuthorizationEvidenceUnavailable, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationUnsupported, + ): + pass + raise ModelCallUnavailable("model_call_authority_unavailable") from error + except ( + AuthorizationDenied, + AuthorizationEvidenceUnavailable, + PreparedAuthorizationHandleInvalid, + ) as error: + if handle is not None: + try: + self._prepared.close_handle(handle) + except PreparedAuthorizationHandleInvalid: + pass + raise ModelCallUnavailable("model_call_authority_unavailable") from error + return ModelActionEvidence( + evidence_id=decision.decision_id, + facts_digest=service_facts_digest, + ) + + def _require_human(self, actor_profile_id: UUID | None = None) -> UUID: + context = self._authorization._context + if not isinstance(context, HumanAuthorizationContext) or ( + actor_profile_id is not None and context.actor_profile_id != actor_profile_id + ): + raise ModelCallUnavailable("model_call_authority_unavailable") + return context.actor_profile_id + + def _require_post_submit_service(self) -> None: + context = self._authorization._context + if ( + not isinstance(context, ServiceAuthorizationContext) + or context.service_identity is not ServiceIdentity.CHECKER_POST_SUBMIT + ): + raise ModelCallUnavailable("model_call_authority_unavailable") + + +__all__ = ("CheckerModelAuthorization",) diff --git a/backend/app/modules/authorization/checker_output_authorization.py b/backend/app/modules/authorization/checker_output_authorization.py new file mode 100644 index 000000000..4516773ce --- /dev/null +++ b/backend/app/modules/authorization/checker_output_authorization.py @@ -0,0 +1,236 @@ +"""Prepared AUTH adapters for private checker-model output custody.""" + +from __future__ import annotations + +from app.modules.actors.api import ServiceIdentity +from app.modules.artifacts.checker_output_bindings import CheckerOutputBindingAuthority +from app.modules.artifacts.checker_output_custody import CheckerOutputStoredFacts +from app.modules.artifacts.checker_outputs import CheckerOutputWriteAuthority +from app.modules.artifacts.schemas import CheckerOutputArtifactAdmissionRequest +from app.modules.authorization.catalogue import ( + ACTION_BY_ID, + SERVICE_ACTIONS_BY_IDENTITY, + ActionAvailability, + ActionId, +) +from app.modules.authorization.domain.checker_outputs import ( + BIND, + WRITE, + checker_output_admission_resource, + checker_output_prepare_resource, + checker_output_stored_resource, +) +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.prepared import ( + PreparedAuthorizationHandle, + PreparedAuthorizationService, + fixed_service_authorization_context, + fixed_service_context_revalidator, +) +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.runtime import ( + AuthorizationDenied, + AuthorizationEvidenceUnavailable, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationInput, + PreparedAuthorizationUnsupported, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, +) +from app.modules.checkers.api.output_custody import ( + CheckerOutputReservation, + CheckerOutputSelector, + CheckerOutputUnavailable, +) + + +_AUTH_ERRORS = ( + AuthorizationDenied, + AuthorizationEvidenceUnavailable, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationUnsupported, +) + + +class _PreparedCheckerOutputAuthorization: + """Own one transaction-local capability from selector through ART facts.""" + + __slots__ = ( + "_action", + "_handle", + "_input", + "_prepared", + "_selector", + "_service_identity", + "_session", + ) + + def __init__( + self, + session, + *, + action: ActionId, + service_identity: ServiceIdentity, + ) -> None: + self._session = session + self._action = action + self._service_identity = service_identity + self._prepared: PreparedAuthorizationService | None = None + self._handle: PreparedAuthorizationHandle | None = None + self._input: PreparedAuthorizationInput | None = None + self._selector: CheckerOutputSelector | None = None + + async def preflight(self, selector: CheckerOutputSelector) -> None: + """Reject malformed or unavailable action shape before protected reads.""" + try: + CheckerOutputSelector.model_validate(selector) + action = ACTION_BY_ID[self._action] + if ( + action.availability is not ActionAvailability.ACTIVE + or self._action not in SERVICE_ACTIONS_BY_IDENTITY.get(self._service_identity, ()) + ): + raise ValueError("checker output action unavailable") + except (KeyError, TypeError, ValueError) as error: + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + + async def prepare(self, selector: CheckerOutputSelector) -> None: + """Lock one fixed-service grant before CHECKERS or ART feature rows.""" + self.discard() + if not self._session.in_transaction() or self._session.in_nested_transaction(): + raise CheckerOutputUnavailable("checker_output_authority_unavailable") + prepared: PreparedAuthorizationService | None = None + try: + resource = checker_output_prepare_resource(self._action, selector) + context = await fixed_service_authorization_context( + self._session, + self._service_identity, + resource.resource_id, + resource.resource_id, + ) + repository = AdminAuthorizationRepository(self._session) + authorization = AuthorizationService( + self._session, + context, + revalidate_service=fixed_service_context_revalidator( + repository, self._service_identity + ), + admin_repository=repository, + ) + prepared = PreparedAuthorizationService( + self._session, context, authorization, repository + ) + caller_input = PreparedAuthorizationInput( + idempotency_key=resource.resource_id, + request_value=resource.model_dump(mode="json"), + ) + handle = await prepared.prepare( + self._action, + caller_input, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=resource.scope_project_id, + ), + ) + except _AUTH_ERRORS as error: + if prepared is not None: + prepared.close() + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + except (TypeError, ValueError) as error: + if prepared is not None: + prepared.close() + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + self._prepared = prepared + self._handle = handle + self._input = caller_input + self._selector = resource.selector + + async def _consume(self, resource) -> None: + prepared, handle, caller_input = self._prepared, self._handle, self._input + if ( + prepared is None + or handle is None + or caller_input is None + or self._selector is None + or resource.selector != self._selector + ): + raise CheckerOutputUnavailable("checker_output_authority_unavailable") + try: + await prepared.consume(handle, self._action, caller_input, resource) + except _AUTH_ERRORS as error: + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + finally: + self.discard() + + def discard(self) -> None: + """Invalidate every unconsumed transaction-local capability.""" + if self._prepared is not None: + self._prepared.close() + self._prepared = None + self._handle = None + self._input = None + self._selector = None + + +class PreparedCheckerOutputWriteAuthorization( + _PreparedCheckerOutputAuthorization, CheckerOutputWriteAuthority +): + """Authorize exact private request/response evidence storage and recovery.""" + + def __init__(self, session) -> None: + super().__init__( + session, + action=WRITE, + service_identity=ServiceIdentity.ARTIFACT_CHECKER_OUTPUT, + ) + + async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: + """Consume server-computed commitment facts without receiving raw bytes.""" + selector = self._require_selector() + if request.slot_key != selector.slot_key: + raise CheckerOutputUnavailable("checker_output_authority_unavailable") + await self._consume(checker_output_admission_resource(selector, request)) + + async def consume_existing( + self, + reservation: CheckerOutputReservation, + facts: CheckerOutputStoredFacts, + ) -> None: + """Consume exact stored ancestry selected under the prepared selector.""" + selector = self._require_selector() + await self._consume(checker_output_stored_resource(WRITE, selector, reservation, facts)) + + def _require_selector(self) -> CheckerOutputSelector: + if self._selector is None: + raise CheckerOutputUnavailable("checker_output_authority_unavailable") + return self._selector + + +class PreparedCheckerOutputBindingAuthorization( + _PreparedCheckerOutputAuthorization, CheckerOutputBindingAuthority +): + """Authorize one binding from verified call-stable evidence.""" + + def __init__(self, session) -> None: + super().__init__( + session, + action=BIND, + service_identity=ServiceIdentity.ARTIFACT_BINDING, + ) + + async def consume( + self, + reservation: CheckerOutputReservation, + facts: CheckerOutputStoredFacts, + ) -> None: + """Consume only complete verified ancestry for the prepared selector.""" + if self._selector is None: + raise CheckerOutputUnavailable("checker_output_authority_unavailable") + await self._consume( + checker_output_stored_resource(BIND, self._selector, reservation, facts) + ) + + +__all__ = ( + "PreparedCheckerOutputBindingAuthorization", + "PreparedCheckerOutputWriteAuthorization", +) diff --git a/backend/app/modules/authorization/domain/action_groups.py b/backend/app/modules/authorization/domain/action_groups.py index d7daf6591..25a68c80b 100644 --- a/backend/app/modules/authorization/domain/action_groups.py +++ b/backend/app/modules/authorization/domain/action_groups.py @@ -3,7 +3,13 @@ from app.modules.authorization.domain.task_authority import TASK_IMPORT_BATCH_ACTIONS, TaskImportBatchResourceContext from app.modules.authorization.catalogue import ActionId -from app.modules.authorization.domain import adapter_bindings, checker_registry, contribution_policies +from app.modules.authorization.domain import ( + adapter_bindings, + checker_models, + checker_outputs, + checker_registry, + contribution_policies, +) from app.modules.authorization.runtime import PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION GUIDE_BOUND_PROJECT_MANAGER_ACTIONS = frozenset( @@ -22,6 +28,7 @@ ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, } ) +SYSTEM_OPERATOR_MODEL_MUTATIONS = checker_models.MODEL_ADMIN_ACTIONS GUIDE_BOUND_PROJECT_MANAGER_ACTIONS |= GUIDE_PROPOSAL_ACTION_IDS | POST_POLICY_HUMAN_ACTION_IDS EXACT_PROJECT_MANAGER_SCOPE_ACTIONS = frozenset({ ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, ActionId.PROJECT_GUIDE_ACTIVATE, @@ -55,6 +62,8 @@ *POST_POLICY_MUTATION_ACTION_IDS, *adapter_bindings.ADAPTER_BINDING_ACTIONS, *contribution_policies.CONTRIBUTION_POLICY_ACTIONS, + *checker_models.CHECKER_MODEL_ACTIONS, + *checker_outputs.CHECKER_OUTPUT_ACTIONS, } ) @@ -72,6 +81,17 @@ def supports_prepared_denial(action_id: ActionId, resource_context: object) -> b ProjectPolicyMutationPrepareDenialResourceContext, ProjectSubmissionArtifactPolicyMutationResourceContext, ) return ( + ( + action_id in checker_outputs.CHECKER_OUTPUT_ACTIONS + and type(resource_context) is checker_outputs.CheckerOutputResourceContext + ) + or + ( + action_id in checker_models.CHECKER_MODEL_ACTIONS + and type(resource_context) + is checker_models.CHECKER_MODEL_RESOURCE_BY_ACTION[action_id] + ) + or ( action_id in checker_registry.CHECKER_REGISTRY_MUTATION_ACTIONS and type(resource_context) diff --git a/backend/app/modules/authorization/domain/audit.py b/backend/app/modules/authorization/domain/audit.py index a347dad9c..460979ffb 100644 --- a/backend/app/modules/authorization/domain/audit.py +++ b/backend/app/modules/authorization/domain/audit.py @@ -24,6 +24,9 @@ "task_submission_history", "submission_history", "checker_history", + "checker_model_call", + "checker_model_output", + "project_model_administration", "artifact_put_attempt", "artifact_verification_job", "artifact_pending_work", @@ -76,6 +79,9 @@ "task_submission_history", "submission_history", "checker_history", + "checker_model_call", + "checker_model_output", + "project_model_administration", "actor_profile", "actor_authorization_context", "project", diff --git a/backend/app/modules/authorization/domain/checker_models.py b/backend/app/modules/authorization/domain/checker_models.py new file mode 100644 index 000000000..489619643 --- /dev/null +++ b/backend/app/modules/authorization/domain/checker_models.py @@ -0,0 +1,330 @@ +"""Exact AUTH resources for project-owned checker model effects.""" + +from __future__ import annotations + +from typing import Literal, Self +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.modules.authorization.catalogue import ActionId +from app.modules.checkers.api.model_calls import ( + ModelBudgetProvisioningFacts, + ModelCallEffectAuthorityFacts, + ModelCallEvidence, + ModelCallExecutionFacts, + ModelPaidSlotKey, + ModelProfileProvisioningFacts, + PreparedModelCallAttempt, + model_action_digest, +) + + +PROFILE_PROVISION = ActionId.PROJECT_MODEL_PROFILE_PROVISION +BUDGET_CONFIGURE = ActionId.PROJECT_MODEL_BUDGET_CONFIGURE +RECOVERY_CHILD_AUTHORIZE = ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE +PREPARE = ActionId.CHECKER_MODEL_CALL_PREPARE +DISPATCH = ActionId.CHECKER_MODEL_CALL_DISPATCH +RECOVER = ActionId.CHECKER_MODEL_CALL_RECOVER +OBSERVE = ActionId.CHECKER_MODEL_CALL_OBSERVE +COMPLETE = ActionId.CHECKER_MODEL_CALL_COMPLETE +GRANT = ActionId.CHECKER_MODEL_CALL_GRANT + +MODEL_ADMIN_ACTIONS = frozenset({PROFILE_PROVISION, BUDGET_CONFIGURE, RECOVERY_CHILD_AUTHORIZE}) +CHECKER_MODEL_CALL_ACTIONS = frozenset({PREPARE, DISPATCH, RECOVER, OBSERVE, COMPLETE, GRANT}) +CHECKER_MODEL_ACTIONS = MODEL_ADMIN_ACTIONS | CHECKER_MODEL_CALL_ACTIONS + +_STRICT_FROZEN = ConfigDict(extra="forbid", frozen=True, strict=True, revalidate_instances="always") +_Sha256 = str + + +class ModelCallPrepareAuthorityFacts(BaseModel): + """Exact current execution, stable slot and first request commitment.""" + + model_config = _STRICT_FROZEN + execution: ModelCallExecutionFacts + slot: ModelPaidSlotKey + request_sha256: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class ModelCallDispatchAuthorityFacts(BaseModel): + """Exact current execution, prepared attempt and verified request evidence.""" + + model_config = _STRICT_FROZEN + execution: ModelCallExecutionFacts + attempt: PreparedModelCallAttempt + request_evidence: ModelCallEvidence + + +class ModelCallRecoverAuthorityFacts(BaseModel): + """A current execution attaching to one existing provider effect.""" + + model_config = _STRICT_FROZEN + execution: ModelCallExecutionFacts + effect: ModelCallEffectAuthorityFacts + + +class ModelCallGrantAuthorityFacts(BaseModel): + """Current execution and opaque invocation-grant commitment.""" + + model_config = _STRICT_FROZEN + execution: ModelCallExecutionFacts + slot: ModelPaidSlotKey + external_request_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + token_hash: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class ModelCallObserveAuthorityFacts(BaseModel): + """Lease-independent uncertain-effect observation facts.""" + + model_config = _STRICT_FROZEN + effect: ModelCallEffectAuthorityFacts + provider_response_id: str | None = Field( + default=None, + min_length=1, + max_length=256, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", + ) + observation_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class ModelCallCompleteAuthorityFacts(BaseModel): + """Lease-independent terminal effect and optional response evidence.""" + + model_config = _STRICT_FROZEN + effect: ModelCallEffectAuthorityFacts + response_evidence: ModelCallEvidence | None + terminal_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class ModelCallRecoveryChildAuthorityFacts(BaseModel): + """Operator-authorized child after one exact terminal parent effect.""" + + model_config = _STRICT_FROZEN + execution: ModelCallExecutionFacts + parent_attempt_id: UUID + terminal_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +RuntimeFacts = ( + ModelCallPrepareAuthorityFacts + | ModelCallDispatchAuthorityFacts + | ModelCallRecoverAuthorityFacts + | ModelCallObserveAuthorityFacts + | ModelCallCompleteAuthorityFacts + | ModelCallGrantAuthorityFacts +) +AdminFacts = ( + ModelProfileProvisioningFacts + | ModelBudgetProvisioningFacts + | ModelCallRecoveryChildAuthorityFacts +) + + +class CheckerModelCallResourceContext(BaseModel): + """One exact fixed-service model-call phase in a project scope.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["checker_model_call"] = "checker_model_call" + resource_id: UUID + scope_project_id: UUID + action_id: ActionId + facts: RuntimeFacts + facts_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + def exact_phase(self) -> Self: + expected = { + PREPARE: ModelCallPrepareAuthorityFacts, + DISPATCH: ModelCallDispatchAuthorityFacts, + RECOVER: ModelCallRecoverAuthorityFacts, + OBSERVE: ModelCallObserveAuthorityFacts, + COMPLETE: ModelCallCompleteAuthorityFacts, + GRANT: ModelCallGrantAuthorityFacts, + }.get(self.action_id) + if expected is None or type(self.facts) is not expected: + raise ValueError("invalid checker model call authority phase") + project_id, resource_id = _runtime_identity(self.facts) + if ( + self.scope_project_id != project_id + or self.resource_id != resource_id + or self.facts_digest != model_action_digest(self.action_id.value, self.facts) + ): + raise ValueError("invalid checker model call authority resource") + return self + + +class ProjectModelAdministrationResourceContext(BaseModel): + """Exact project target governed only by a current system Operator grant.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["project_model_administration"] = "project_model_administration" + resource_id: UUID + project_id: UUID + actor_profile_id: UUID + action_id: ActionId + facts: AdminFacts + facts_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + def exact_operation(self) -> Self: + expected = { + PROFILE_PROVISION: ModelProfileProvisioningFacts, + BUDGET_CONFIGURE: ModelBudgetProvisioningFacts, + RECOVERY_CHILD_AUTHORIZE: ModelCallRecoveryChildAuthorityFacts, + }.get(self.action_id) + if expected is None or type(self.facts) is not expected: + raise ValueError("invalid project model administration phase") + project_id, resource_id, actor_profile_id = _admin_identity( + self.facts, self.actor_profile_id + ) + if ( + self.project_id != project_id + or self.resource_id != resource_id + or self.actor_profile_id != actor_profile_id + or self.facts_digest != model_action_digest(self.action_id.value, self.facts) + ): + raise ValueError("invalid project model administration resource") + return self + + +CHECKER_MODEL_RESOURCE_BY_ACTION = { + **{action: CheckerModelCallResourceContext for action in CHECKER_MODEL_CALL_ACTIONS}, + **{action: ProjectModelAdministrationResourceContext for action in MODEL_ADMIN_ACTIONS}, +} + + +def checker_model_call_resource( + action: ActionId, facts: RuntimeFacts +) -> CheckerModelCallResourceContext: + """Build the only valid fixed-service resource for one phase.""" + project_id, resource_id = _runtime_identity(facts) + return CheckerModelCallResourceContext( + resource_id=resource_id, + scope_project_id=project_id, + action_id=action, + facts=facts, + facts_digest=model_action_digest(action.value, facts), + ) + + +def project_model_admin_resource( + action: ActionId, + facts: AdminFacts, + *, + actor_profile_id: UUID, +) -> ProjectModelAdministrationResourceContext: + """Build one system-Operator resource retaining its exact project target.""" + project_id, resource_id, checked_actor = _admin_identity(facts, actor_profile_id) + return ProjectModelAdministrationResourceContext( + resource_id=resource_id, + project_id=project_id, + actor_profile_id=checked_actor, + action_id=action, + facts=facts, + facts_digest=model_action_digest(action.value, facts), + ) + + +def parse_checker_model_prepare( + action: ActionId, value: object, invalid_error: type[Exception] +) -> CheckerModelCallResourceContext | ProjectModelAdministrationResourceContext | None: + """Parse the exact resource committed before AUTH locks product owners.""" + expected = CHECKER_MODEL_RESOURCE_BY_ACTION.get(action) + if expected is None: + return None + try: + resource = expected.model_validate(value) + if resource.action_id is not action: + raise ValueError("checker model action mismatch") + return resource + except (TypeError, ValueError) as error: + raise invalid_error("invalid prepared checker model authority") from error + + +def checker_model_prepare_matches( + prepared: CheckerModelCallResourceContext | ProjectModelAdministrationResourceContext | None, + resource: object, +) -> bool: + """Require final facts to equal the resource committed at preparation.""" + return prepared is not None and type(resource) is type(prepared) and resource == prepared + + +def _runtime_identity(facts: RuntimeFacts) -> tuple[UUID, UUID]: + if type(facts) is ModelCallPrepareAuthorityFacts: + if not _execution_matches_slot(facts.execution, facts.slot): + raise ValueError("checker model project mismatch") + return facts.execution.project_id, facts.execution.evaluation_request_id + if type(facts) is ModelCallDispatchAuthorityFacts: + if not _execution_matches_slot(facts.execution, facts.attempt.slot_key): + raise ValueError("checker model project mismatch") + return facts.execution.project_id, facts.attempt.attempt_id + if type(facts) is ModelCallRecoverAuthorityFacts: + if ( + not _execution_matches_slot(facts.execution, facts.effect.slot_key) + or facts.execution.project_id != facts.effect.project_id + or facts.execution.task_id != facts.effect.task_id + ): + raise ValueError("checker model recovery effect mismatch") + return facts.execution.project_id, facts.effect.attempt_id + if type(facts) is ModelCallGrantAuthorityFacts: + if not _execution_matches_slot(facts.execution, facts.slot): + raise ValueError("checker model grant project mismatch") + return facts.execution.project_id, facts.execution.evaluation_request_id + if type(facts) in {ModelCallObserveAuthorityFacts, ModelCallCompleteAuthorityFacts}: + if facts.effect.project_id != facts.effect.slot_key.project_id: + raise ValueError("checker model project mismatch") + return facts.effect.project_id, facts.effect.attempt_id + raise ValueError("invalid checker model runtime facts") + + +def _execution_matches_slot(execution: ModelCallExecutionFacts, slot: ModelPaidSlotKey) -> bool: + return ( + execution.project_id, + execution.assignment_id, + execution.submission_id, + execution.submission_version, + ) == ( + slot.project_id, + slot.assignment_id, + slot.submission_id, + slot.submission_version, + ) + + +def _admin_identity(facts: AdminFacts, actor_profile_id: UUID) -> tuple[UUID, UUID, UUID]: + if type(facts) in {ModelProfileProvisioningFacts, ModelBudgetProvisioningFacts}: + return facts.spec.project_id, facts.operation_id, facts.actor_profile_id + if type(facts) is ModelCallRecoveryChildAuthorityFacts: + return facts.execution.project_id, facts.parent_attempt_id, actor_profile_id + raise ValueError("invalid project model administration facts") + + +__all__ = ( + "BUDGET_CONFIGURE", + "CHECKER_MODEL_ACTIONS", + "CHECKER_MODEL_CALL_ACTIONS", + "CHECKER_MODEL_RESOURCE_BY_ACTION", + "COMPLETE", + "CheckerModelCallResourceContext", + "DISPATCH", + "GRANT", + "MODEL_ADMIN_ACTIONS", + "ModelCallCompleteAuthorityFacts", + "ModelCallDispatchAuthorityFacts", + "ModelCallGrantAuthorityFacts", + "ModelCallObserveAuthorityFacts", + "ModelCallPrepareAuthorityFacts", + "ModelCallRecoverAuthorityFacts", + "ModelCallRecoveryChildAuthorityFacts", + "OBSERVE", + "PREPARE", + "PROFILE_PROVISION", + "ProjectModelAdministrationResourceContext", + "RECOVER", + "RECOVERY_CHILD_AUTHORIZE", + "checker_model_call_resource", + "checker_model_prepare_matches", + "parse_checker_model_prepare", + "project_model_admin_resource", +) diff --git a/backend/app/modules/authorization/domain/checker_outputs.py b/backend/app/modules/authorization/domain/checker_outputs.py new file mode 100644 index 000000000..5134192bd --- /dev/null +++ b/backend/app/modules/authorization/domain/checker_outputs.py @@ -0,0 +1,262 @@ +"""Exact prepared resources for private checker-model output custody.""" + +from __future__ import annotations + +from typing import Literal, Self +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.core.hashing import canonical_json_hash +from app.modules.authorization.catalogue import ActionId +from app.modules.checkers.api.output_custody import ( + CheckerOutputReservation, + CheckerOutputSelector, +) + + +WRITE = ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE +BIND = ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE +CHECKER_OUTPUT_ACTIONS = frozenset({WRITE, BIND}) + +_STRICT_FROZEN = ConfigDict(extra="forbid", frozen=True, strict=True, revalidate_instances="always") + + +class CheckerOutputCommitmentFacts(BaseModel): + """Server-computed content commitment without source bytes or scratch identity.""" + + model_config = _STRICT_FROZEN + sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + byte_count: int = Field(strict=True, ge=0, le=1_048_576) + media_type: Literal["application/json"] + + +class CheckerOutputStoredAuthorityFacts(BaseModel): + """Verified ART ancestry selected under the exact model-call reservation.""" + + model_config = _STRICT_FROZEN + put_attempt_id: UUID + checker_request_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + byte_count: int = Field(strict=True, ge=0, le=1_048_576) + media_type: Literal["application/json"] + status: Literal[ + "prepared", + "put_in_flight", + "acknowledgement_unknown", + "object_confirmed", + "absent_replay_required", + "integrity_mismatch", + "provider_unavailable", + "conflict", + "verified", + ] + content_id: UUID | None + replica_id: UUID | None + verification_receipt_id: UUID | None + + @model_validator(mode="after") + def exact_verification(self) -> Self: + present = ( + self.content_id is not None, + self.replica_id is not None, + self.verification_receipt_id is not None, + ) + if self.status == "verified" and not all(present): + raise ValueError("verified checker output ancestry is incomplete") + if self.status != "verified" and any(present): + raise ValueError("unverified checker output has verified ancestry") + return self + + +class CheckerOutputResourceContext(BaseModel): + """One exact selector prepared before owner locks and closed by stored facts.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["checker_model_output"] = "checker_model_output" + resource_id: UUID + scope_project_id: UUID + action_id: ActionId + selector: CheckerOutputSelector + selector_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + reservation: CheckerOutputReservation | None = None + commitment: CheckerOutputCommitmentFacts | None = None + stored: CheckerOutputStoredAuthorityFacts | None = None + + @model_validator(mode="after") + def exact_selector(self) -> Self: + if self.action_id not in CHECKER_OUTPUT_ACTIONS: + raise ValueError("invalid checker output action") + if ( + self.resource_id != self.selector.model_call_attempt_id + or self.scope_project_id != self.selector.paid_slot.project_id + or self.selector_digest != checker_output_selector_digest(self.selector) + ): + raise ValueError("invalid checker output selector resource") + if self.reservation is not None: + slot = self.reservation.select(self.selector) + if self.commitment is not None and ( + self.commitment.byte_count > slot.maximum_bytes + or self.commitment.media_type != slot.media_type + ): + raise ValueError("checker output commitment exceeds its reservation") + if self.stored is not None and ( + self.stored.byte_count > slot.maximum_bytes + or self.stored.media_type != slot.media_type + or self.stored.checker_request_digest + != canonical_json_hash(_checker_output_request_digest_facts(self.reservation, slot)) + ): + raise ValueError("stored checker output differs from its reservation") + if self.commitment is not None and self.action_id is not WRITE: + raise ValueError("checker output commitment requires write authority") + if self.stored is not None and self.stored.sha256 != self.selector.content_sha256: + raise ValueError("stored checker output differs from selector") + if ( + self.reservation is not None + and self.action_id is BIND + and (self.stored is None or self.stored.status != "verified") + ): + raise ValueError("checker output binding requires verified ancestry") + if self.commitment is not None and self.commitment.sha256 != self.selector.content_sha256: + raise ValueError("checker output commitment differs from selector") + if self.commitment is not None and self.stored is not None: + raise ValueError("checker output authority facts are ambiguous") + return self + + +def checker_output_selector_digest(selector: CheckerOutputSelector) -> str: + """Hash the entire typed selector prepared before protected owner access.""" + checked = CheckerOutputSelector.model_validate(selector) + return canonical_json_hash(checked.model_dump(mode="json")) + + +def checker_output_prepare_resource( + action: ActionId, selector: CheckerOutputSelector +) -> CheckerOutputResourceContext: + """Build the preliminary resource bound only to the caller's exact selector.""" + checked = CheckerOutputSelector.model_validate(selector) + return CheckerOutputResourceContext( + resource_id=checked.model_call_attempt_id, + scope_project_id=checked.paid_slot.project_id, + action_id=action, + selector=checked, + selector_digest=checker_output_selector_digest(checked), + ) + + +def checker_output_admission_resource( + selector: CheckerOutputSelector, + request: object, +) -> CheckerOutputResourceContext: + """Bind prepared write authority to server-computed content facts.""" + commitment = request.source.commitment + prepared = checker_output_prepare_resource(WRITE, selector) + return CheckerOutputResourceContext( + resource_id=prepared.resource_id, + scope_project_id=prepared.scope_project_id, + action_id=prepared.action_id, + selector=prepared.selector, + selector_digest=prepared.selector_digest, + reservation=request.reservation, + commitment=CheckerOutputCommitmentFacts( + sha256=commitment.sha256, + byte_count=commitment.byte_count, + media_type=commitment.media_type, + ), + ) + + +def checker_output_stored_resource( + action: ActionId, + selector: CheckerOutputSelector, + reservation: CheckerOutputReservation, + facts: object, +) -> CheckerOutputResourceContext: + """Bind prepared authority to storage facts selected by ART itself.""" + prepared = checker_output_prepare_resource(action, selector) + return CheckerOutputResourceContext( + resource_id=prepared.resource_id, + scope_project_id=prepared.scope_project_id, + action_id=prepared.action_id, + selector=prepared.selector, + selector_digest=prepared.selector_digest, + reservation=reservation, + stored=CheckerOutputStoredAuthorityFacts( + put_attempt_id=facts.put_attempt_id, + checker_request_digest=facts.checker_request_digest, + sha256=facts.sha256, + byte_count=facts.byte_count, + media_type=facts.media_type, + status=facts.status, + content_id=facts.content_id, + replica_id=facts.replica_id, + verification_receipt_id=facts.verification_receipt_id, + ), + ) + + +def _checker_output_request_digest_facts(reservation, slot) -> dict[str, object]: + """Mirror ART's stable owner facts without importing the ART runtime.""" + selector = reservation.selector + return { + "model_call_slot_id": str(selector.model_call_slot_id), + "model_call_attempt_id": str(selector.model_call_attempt_id), + "paid_slot": selector.paid_slot.model_dump(mode="json"), + "task_id": str(selector.task_id), + "content_sha256": selector.content_sha256, + "slot_key": slot.key, + "media_type": slot.media_type, + "maximum_bytes": slot.maximum_bytes, + } + + +def parse_checker_output_prepare( + action: ActionId, value: object, invalid_error: type[Exception] +) -> CheckerOutputResourceContext | None: + """Parse only preliminary selector resources at PREP issuance.""" + if action not in CHECKER_OUTPUT_ACTIONS: + return None + try: + resource = CheckerOutputResourceContext.model_validate(value) + if ( + resource.action_id is not action + or resource.reservation is not None + or resource.commitment is not None + or resource.stored is not None + ): + raise ValueError("checker output prepare resource is not preliminary") + return resource + except (TypeError, ValueError) as error: + raise invalid_error("invalid prepared checker output authority") from error + + +def checker_output_prepare_matches( + prepared: CheckerOutputResourceContext | None, + resource: object, +) -> bool: + """Require final facts to retain the exact selector committed at preparation.""" + return ( + prepared is not None + and type(resource) is CheckerOutputResourceContext + and resource.action_id is prepared.action_id + and resource.resource_id == prepared.resource_id + and resource.scope_project_id == prepared.scope_project_id + and resource.selector == prepared.selector + and resource.selector_digest == prepared.selector_digest + and resource.reservation is not None + and ((resource.commitment is not None) ^ (resource.stored is not None)) + ) + + +__all__ = ( + "BIND", + "CHECKER_OUTPUT_ACTIONS", + "CheckerOutputResourceContext", + "WRITE", + "checker_output_admission_resource", + "checker_output_prepare_matches", + "checker_output_prepare_resource", + "checker_output_selector_digest", + "checker_output_stored_resource", + "parse_checker_output_prepare", +) diff --git a/backend/app/modules/authorization/domain/prepared_service.py b/backend/app/modules/authorization/domain/prepared_service.py index 13de67002..2fa9350ec 100644 --- a/backend/app/modules/authorization/domain/prepared_service.py +++ b/backend/app/modules/authorization/domain/prepared_service.py @@ -6,6 +6,14 @@ from app.core.hashing import canonical_json_hash from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.checker_models import ( + CHECKER_MODEL_CALL_ACTIONS, + CheckerModelCallResourceContext, +) +from app.modules.authorization.domain.checker_outputs import ( + CHECKER_OUTPUT_ACTIONS, + CheckerOutputResourceContext, +) from app.modules.authorization.domain.post_submit_routing import ROUTE, PostSubmitRoutingResourceContext, parse_post_submit_routing_prepare from app.modules.authorization.domain.post_submit import ( POST_SUBMIT_ACTIONS, PostSubmitResourceContext, parse_post_submit_prepare, @@ -115,7 +123,12 @@ def fixed_service_scope_project(action_id, scope, artifact_resource): ActionId.TASK_REVISION_EXPIRE, ActionId.TASK_REVISION_RECOVER} and scope.kind is PreparedAuthorityScopeKind.PROJECT and scope.project_id is not None) if is_project_setup_scope(action_id, scope) or outbox or ( - action_id in PRE_SUBMIT_ACTIONS | POST_SUBMIT_ACTIONS | {ROUTE} + action_id + in PRE_SUBMIT_ACTIONS + | POST_SUBMIT_ACTIONS + | CHECKER_MODEL_CALL_ACTIONS + | CHECKER_OUTPUT_ACTIONS + | {ROUTE} and scope.kind is PreparedAuthorityScopeKind.PROJECT and scope.project_id is not None ): @@ -134,6 +147,18 @@ def fixed_service_resource_matches(action_id, resource, project_id, artifact_typ if action_id in POST_SUBMIT_ACTIONS: return (type(resource) is PostSubmitResourceContext and resource.action_id is action_id and resource.scope_project_id == project_id) + if action_id in CHECKER_MODEL_CALL_ACTIONS: + return ( + type(resource) is CheckerModelCallResourceContext + and resource.action_id is action_id + and resource.scope_project_id == project_id + ) + if action_id in CHECKER_OUTPUT_ACTIONS: + return ( + type(resource) is CheckerOutputResourceContext + and resource.action_id is action_id + and resource.scope_project_id == project_id + ) if action_id in PRE_SUBMIT_ACTIONS: return ( type(resource) is PreSubmitResourceContext diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 6e6ab475b..493d21d6c 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -24,7 +24,7 @@ PermissionId, ) from app.modules.authorization.domain.guide_manager_resources import guide_manager_resource_denial -from app.modules.authorization.domain import adapter_bindings, checker_registry, checker_retry, contribution_policies, guide_compilation as compilation, post_submit_deliveries +from app.modules.authorization.domain import adapter_bindings, checker_models, checker_registry, checker_retry, contribution_policies, guide_compilation as compilation, post_submit_deliveries from app.modules.authorization.domain.action_groups import ( GUIDE_BOUND_PROJECT_MANAGER_ACTIONS as _GUIDE_BOUND_PROJECT_MANAGER_ACTIONS, CONTEXT_DIGEST_ACTIONS, @@ -152,7 +152,7 @@ ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, ActionId.PROJECT_ACTIVE_GUIDE_READ, } -) | adapter_bindings.ADAPTER_BINDING_READ_ACTIONS | contribution_policies.CONTRIBUTION_POLICY_READ_ACTIONS | checker_registry.CHECKER_REGISTRY_ACTIONS | checker_retry.CHECKER_RETRY_ACTIONS | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS +) | adapter_bindings.ADAPTER_BINDING_READ_ACTIONS | contribution_policies.CONTRIBUTION_POLICY_READ_ACTIONS | checker_registry.CHECKER_REGISTRY_ACTIONS | checker_models.MODEL_ADMIN_ACTIONS | checker_retry.CHECKER_RETRY_ACTIONS | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS _SERIALIZED_ADMIN_READS = frozenset( { ActionId.ACTOR_PROFILE_READ, @@ -178,7 +178,7 @@ ActionId.ACTOR_IDENTITY_LINK_REVOKE, ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, } -) | PROJECT_SCOPED_ADMIN_MUTATIONS | checker_registry.CHECKER_REGISTRY_MUTATION_ACTIONS | checker_retry.CHECKER_RETRY_ACTIONS | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS +) | PROJECT_SCOPED_ADMIN_MUTATIONS | checker_registry.CHECKER_REGISTRY_MUTATION_ACTIONS | checker_models.MODEL_ADMIN_ACTIONS | checker_retry.CHECKER_RETRY_ACTIONS | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS def _prepared_admin_role_locker(action_id): """Select the only supported prepared admin-role authority locker.""" @@ -260,6 +260,10 @@ async def prelock_serialized_admin_read_actor( ActionId.PROJECT_ACTIVE_GUIDE_READ: ProjectActiveGuideReadResourceContext, **adapter_bindings.ADAPTER_BINDING_RESOURCE_BY_ACTION, **contribution_policies.CONTRIBUTION_POLICY_RESOURCE_BY_ACTION, **checker_registry.CHECKER_REGISTRY_RESOURCE_BY_ACTION, + **{ + action: checker_models.ProjectModelAdministrationResourceContext + for action in checker_models.MODEL_ADMIN_ACTIONS + }, ActionId.OPERATIONS_CHECKER_RETRY: checker_retry.CheckerRetryResourceContext, **post_submit_deliveries.POST_SUBMIT_DELIVERY_RESOURCE_BY_ACTION, **PROJECT_MUTATION_RESOURCE_BY_ACTION, **GUIDE_PROPOSAL_RESOURCE_BY_ACTION, @@ -956,7 +960,11 @@ async def _admin_denial( adapter_bindings.finance_authority_grant_filters(action.action_id) ) grant_filters.update({**contribution_policies.policy_finance_grant_filters(action.action_id), **checker_registry.checker_registry_grant_filters(action.action_id)}) - if action.action_id in checker_retry.CHECKER_RETRY_ACTIONS | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS: + if action.action_id in ( + checker_retry.CHECKER_RETRY_ACTIONS + | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS + | checker_models.MODEL_ADMIN_ACTIONS + ): grant_filters["allowed_roles"] = (AdminRole.OPERATOR,) matched = await self._admin.find_effective_grant( context.actor_profile_id, @@ -1040,6 +1048,11 @@ async def _admin_guard( return AuthorizationDenialCode.RESOURCE_NOT_FOUND elif type(resource) is post_submit_deliveries.PostSubmitDeliveryResourceContext and not await self._admin.project_exists(resource.scope_project_id, for_update=True): return AuthorizationDenialCode.RESOURCE_NOT_FOUND + elif type(resource) is checker_models.ProjectModelAdministrationResourceContext: + if resource.actor_profile_id != context.actor_profile_id: + return AuthorizationDenialCode.RESOURCE_GUARD_DENIED + if not await self._admin.project_exists(resource.project_id, for_update=True): + return AuthorizationDenialCode.RESOURCE_NOT_FOUND elif isinstance( resource, (AdminRoleGrantCollectionResourceContext, ActorAdminRoleGrantHistoryResourceContext), diff --git a/backend/app/modules/authorization/policy.py b/backend/app/modules/authorization/policy.py index 82a70910e..3b2f8afd0 100644 --- a/backend/app/modules/authorization/policy.py +++ b/backend/app/modules/authorization/policy.py @@ -45,6 +45,9 @@ PermissionId.OPERATIONS_TASK_START_OVERRIDE, PermissionId.OPERATIONS_SUBMISSION_GATE_REPAIR, PermissionId.OPERATIONS_CHECKER_RETRY, + PermissionId.PROJECT_MODEL_PROFILE_MANAGE, + PermissionId.PROJECT_MODEL_BUDGET_MANAGE, + PermissionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, PermissionId.ARTIFACT_BINDING_READ, PermissionId.ARTIFACT_REPLICA_READ, PermissionId.ARTIFACT_RECEIPT_READ, diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index 4f0ab9180..bf0352039 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -47,6 +47,19 @@ checker_retry_resource_matches, parse_checker_retry_prepare, ) +from app.modules.authorization.domain.checker_models import ( + CHECKER_MODEL_ACTIONS, + CheckerModelCallResourceContext, + ProjectModelAdministrationResourceContext, + checker_model_prepare_matches, + parse_checker_model_prepare, +) +from app.modules.authorization.domain.checker_outputs import ( + CHECKER_OUTPUT_ACTIONS, + CheckerOutputResourceContext, + checker_output_prepare_matches, + parse_checker_output_prepare, +) from app.modules.checkers.api.execution import CheckerRetryPreparation from app.modules.authorization.domain.prepared_submission_policy import parse_submission_policy_prepare from app.modules.authorization.domain.prepared_compilation import prepared_compilation_matches @@ -272,6 +285,12 @@ class _PreparedAuthorizationBinding: adapter_binding_resource_digest: str | None = None guide_projection_prepare_context: dict | None = None setup_finalization_prepare_context: dict | None = None + checker_model_context: ( + CheckerModelCallResourceContext + | ProjectModelAdministrationResourceContext + | None + ) = None + checker_output_context: CheckerOutputResourceContext | None = None TASK_IMPORT_SOURCE_ACTIONS = frozenset({ @@ -662,6 +681,20 @@ def _validate_consumption( issuance.authority.context.identity_link_id, historical_checker_retry_link, ): raise PreparedAuthorizationHandleInvalid("invalid prepared checker retry authority") + if expected_action_id in CHECKER_MODEL_ACTIONS and not checker_model_prepare_matches( + issuance.binding.checker_model_context, + final_resource_context, + ): + raise PreparedAuthorizationHandleInvalid( + "invalid prepared checker model authority" + ) + if expected_action_id in CHECKER_OUTPUT_ACTIONS and not checker_output_prepare_matches( + issuance.binding.checker_output_context, + final_resource_context, + ): + raise PreparedAuthorizationHandleInvalid( + "invalid prepared checker output authority" + ) async def consume( self, @@ -966,6 +999,16 @@ def _binding( **parse_prepared_contribution_policy(action_id, caller_input.request_value), **parse_review_bindings(action_id, caller_input, scope, self._context), **setup_bindings, + checker_model_context=parse_checker_model_prepare( + action_id, + caller_input.request_value, + PreparedAuthorizationHandleInvalid, + ), + checker_output_context=parse_checker_output_prepare( + action_id, + caller_input.request_value, + PreparedAuthorizationHandleInvalid, + ), ) @staticmethod @@ -985,6 +1028,16 @@ def _scope_from_resource( kind=PreparedAuthorityScopeKind.PROJECT, project_id=resource.scope_project_id, ) + if isinstance(resource, CheckerModelCallResourceContext): + return PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=resource.scope_project_id, + ) + if isinstance(resource, CheckerOutputResourceContext): + return PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=resource.scope_project_id, + ) if action_id is ActionId.ACTOR_PROFILE_UPDATE_SELF and isinstance( resource, ActorSelfResourceContext ): diff --git a/backend/app/modules/authorization/prepared_admin_authority.py b/backend/app/modules/authorization/prepared_admin_authority.py index 4b6ff90bb..24dfdf9bd 100644 --- a/backend/app/modules/authorization/prepared_admin_authority.py +++ b/backend/app/modules/authorization/prepared_admin_authority.py @@ -4,6 +4,7 @@ from app.modules.authorization.catalogue import ActionAvailability, ActionId, PermissionId from app.modules.authorization.domain import ( adapter_bindings, + checker_models, checker_registry, contribution_policies, ) @@ -50,9 +51,16 @@ async def lock_admin_mutation_authority(repository, context, scope, action_id, p scope_project_id=scope.project_id, system_scope_only=scope.project_id is None, for_update=True, - **({"allowed_roles": (AdminRole.OPERATOR,)} if action_id in { - ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, ActionId.OPERATIONS_CHECKER_RETRY, - } else {}), + **( + {"allowed_roles": (AdminRole.OPERATOR,)} + if action_id + in { + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, + ActionId.OPERATIONS_CHECKER_RETRY, + *checker_models.MODEL_ADMIN_ACTIONS, + } + else {} + ), **adapter_bindings.finance_authority_grant_filters(action_id), **checker_registry.checker_registry_grant_filters(action_id), **contribution_policies.policy_finance_grant_filters(action_id), diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index de7170d2b..28ecdba3a 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -27,7 +27,7 @@ from app.modules.authorization.domain.project_create import ProjectCreateResourceContext from app.modules.authorization.domain import ( checker_registry, checker_retry, checker_retry_reads, contribution_reads, - contributor_reads, management_reads, submission_history, task_authority, task_queues, + checker_models, checker_outputs, contributor_reads, management_reads, submission_history, task_authority, task_queues, post_submit_deliveries, ) from app.modules.authorization.domain.guide_mutations import ( @@ -1058,6 +1058,9 @@ class PreSubmitCheckerInputResourceContext(PreSubmitCheckerInputPreparationConte AuthorizationResourceContext = ( ReviewLifecycleActivationContract | contribution_reads.ContributionSelfReadResourceContext | contribution_reads.ContributionProjectReadResourceContext + | checker_models.CheckerModelCallResourceContext + | checker_models.ProjectModelAdministrationResourceContext + | checker_outputs.CheckerOutputResourceContext | contributor_reads.ActorProjectListResourceContext | contributor_reads.ContributorWorkReadResourceContext | management_reads.ProjectManagementReadResourceContext | submission_history.HistoryReadResourceContext | task_queues.QueueReadResourceContext diff --git a/backend/app/modules/checkers/api/external_policy.py b/backend/app/modules/checkers/api/external_policy.py index 651e51f9a..71872248b 100644 --- a/backend/app/modules/checkers/api/external_policy.py +++ b/backend/app/modules/checkers/api/external_policy.py @@ -4,6 +4,7 @@ import json from typing import Literal, Self +from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator @@ -20,6 +21,12 @@ ExternalCheckerRegistryPort, external_checker_json_hash, ) +from app.modules.checkers.api.model_calls import ModelProfileReaderPort +from app.modules.checkers.api.model_profiles import ( + ModelProfileConfigurationError, + configured_model_profile_selector, + require_model_profile_configuration, +) WORKSTREAM_DEFAULT_EXECUTION_BUDGET_MS = 60_000 @@ -60,6 +67,10 @@ def validate_binding(self) -> Self: if self.registry.phase != self.stage: raise ExternalCheckerPolicyError("checker binding phase mismatch") self.registry.configuration_schema.validate_instance(self.configuration) + try: + configured_model_profile_selector(self.configuration, stage=self.stage) + except ModelProfileConfigurationError as exc: + raise ExternalCheckerPolicyError(str(exc)) from exc if self.configuration_sha256 != external_checker_json_hash(self.configuration): raise ExternalCheckerPolicyError("checker binding configuration mismatch") return self @@ -185,6 +196,9 @@ def _validate_entries( async def compile_external_checker_policies( result: object, registry: ExternalCheckerRegistryPort, + *, + project_id: UUID | None = None, + profiles: ModelProfileReaderPort | None = None, ) -> tuple[LockedExternalPreSubmitPolicy, LockedExternalPostSubmitPolicy]: """Resolve every supported binding to exact immutable registry custody.""" from app.interfaces.project_agents import ProjectGuideCompilationResult @@ -195,8 +209,22 @@ async def compile_external_checker_policies( requirements = {item.requirement_id: item for item in result.requirements} if len(requirements) != len(result.requirements): raise ExternalCheckerPolicyError("checker requirements are duplicated") - pre = await _resolve(result.pre_submit_bindings, requirements, registry, "pre_submit") - post = await _resolve(result.post_submit_bindings, requirements, registry, "post_submit") + pre = await _resolve( + result.pre_submit_bindings, + requirements, + registry, + "pre_submit", + project_id=project_id, + profiles=profiles, + ) + post = await _resolve( + result.post_submit_bindings, + requirements, + registry, + "post_submit", + project_id=project_id, + profiles=profiles, + ) return _make_policy(LockedExternalPreSubmitPolicy, pre), _make_policy( LockedExternalPostSubmitPolicy, post ) @@ -205,9 +233,17 @@ async def compile_external_checker_policies( async def compile_external_checker_policy_bundle( result: object, registry: ExternalCheckerRegistryPort, + *, + project_id: UUID | None = None, + profiles: ModelProfileReaderPort | None = None, ) -> LockedExternalCheckerPolicies: """Lock both execution phases into one immutable approval-time value.""" - pre, post = await compile_external_checker_policies(result, registry) + pre, post = await compile_external_checker_policies( + result, + registry, + project_id=project_id, + profiles=profiles, + ) body = { "schema_version": "external_checker_policies.v1", "pre_submit": pre.model_dump(mode="json"), @@ -220,7 +256,15 @@ async def compile_external_checker_policy_bundle( ) -async def _resolve(bindings, requirements, registry, phase): +async def _resolve( + bindings, + requirements, + registry, + phase, + *, + project_id: UUID | None, + profiles: ModelProfileReaderPort | None, +): from app.interfaces.project_agents import ( RequirementDisposition, ) @@ -235,9 +279,10 @@ async def _resolve(bindings, requirements, registry, phase): bindings, key=lambda item: (item.requirement_id, item.capability_id, item.capability_version), ): + requirement = requirements.get(binding.requirement_id) if ( - requirements.get(binding.requirement_id) is None - or requirements[binding.requirement_id].disposition is not expected + requirement is None + or requirement.disposition is not expected or binding.stage.value != phase ): raise ExternalCheckerPolicyError("checker binding does not cover requirement") @@ -256,6 +301,28 @@ async def _resolve(bindings, requirements, registry, phase): parameter.name: parameter.model_dump(mode="json")["value"] for parameter in binding.parameters } + try: + selector = configured_model_profile_selector(configuration, stage=phase) + specification = requirement.check_specification + model_required = ( + specification is not None and specification.evaluation_method == "model_evaluation" + ) + if model_required != (selector is not None): + raise ModelProfileConfigurationError( + "model evaluation profile configuration is invalid" + ) + if selector is not None: + if project_id is None: + raise ModelProfileConfigurationError("model profile project is unavailable") + await require_model_profile_configuration( + configuration, + stage=phase, + project_id=project_id, + registry=entry, + profiles=profiles, + ) + except ModelProfileConfigurationError as exc: + raise ExternalCheckerPolicyError(str(exc)) from exc resolved.append( LockedExternalCheckerBinding( requirement_id=binding.requirement_id, @@ -273,6 +340,27 @@ async def _resolve(bindings, requirements, registry, phase): return tuple(resolved) +async def require_locked_model_profiles( + policies: LockedExternalCheckerPolicies, + *, + project_id: UUID, + profiles: ModelProfileReaderPort | None, +) -> None: + """Revalidate every exact selected profile before readiness or activation.""" + policies = LockedExternalCheckerPolicies.model_validate(policies) + for binding in (*policies.pre_submit.entries, *policies.post_submit.entries): + try: + await require_model_profile_configuration( + binding.configuration, + stage=binding.stage, + project_id=project_id, + registry=binding.registry, + profiles=profiles, + ) + except ModelProfileConfigurationError as exc: + raise ExternalCheckerPolicyError(str(exc)) from exc + + def _make_policy(model, entries): hash_body = { "schema_version": model.model_fields["schema_version"].default, diff --git a/backend/app/modules/checkers/api/model_calls.py b/backend/app/modules/checkers/api/model_calls.py new file mode 100644 index 000000000..27da84df8 --- /dev/null +++ b/backend/app/modules/checkers/api/model_calls.py @@ -0,0 +1,608 @@ +"""Typed contracts for project-image model calls and lifetime project spend.""" + +from __future__ import annotations + +from typing import Annotated, Literal, Protocol, Self +from uuid import RFC_4122, UUID + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + StrictInt, + StrictStr, + model_validator, +) + +from app.core.hashing import canonical_json_hash + + +Sha256 = Annotated[StrictStr, Field(pattern=r"^sha256:[0-9a-f]{64}$")] +Identifier = Annotated[ + StrictStr, Field(min_length=1, max_length=100, pattern=r"^[a-z][a-z0-9_.-]*$") +] +ProviderIdentifier = Annotated[ + StrictStr, + Field(min_length=1, max_length=160, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$"), +] +ProviderResponseIdentifier = Annotated[ + StrictStr, + Field(min_length=1, max_length=256, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$"), +] +Nanodollars = Annotated[StrictInt, Field(ge=0, le=9_000_000_000_000_000_000)] + + +class ModelCallUnavailable(RuntimeError): + """Conceal unavailable, foreign, stale or unauthorized model-call state.""" + + +class ModelCallConflict(ModelCallUnavailable): + """Reject immutable identity or replay substitution.""" + + +class ModelCallBudgetUnavailable(ModelCallUnavailable): + """Report a finite project budget that cannot cover the next dispatch.""" + + +class ModelCallValue(BaseModel): + """Strict immutable values shared across model-call owner boundaries.""" + + model_config = ConfigDict( + extra="forbid", frozen=True, strict=True, revalidate_instances="always" + ) + + +def _require_uuid7(value: UUID, name: str) -> UUID: + if value.version != 7 or value.variant != RFC_4122: + raise ValueError(f"{name} must be UUIDv7") + return value + + +class ModelProfileSelector(ModelCallValue): + """Exact immutable profile identity retained in approved checker configuration.""" + + profile_id: UUID + profile_version: StrictInt = Field(ge=1) + profile_digest: Sha256 + + @model_validator(mode="after") + def canonical_id(self) -> Self: + _require_uuid7(self.profile_id, "model profile ID") + return self + + +class ModelGenerationSettings(ModelCallValue): + """Closed provider-neutral settings; prompts never belong to a profile.""" + + reasoning_effort: Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"] + text_verbosity: Literal["low", "medium", "high"] + + +class ModelPricing(ModelCallValue): + """Exact integer nano-USD rates with no floating-point rounding.""" + + pricing_policy_id: UUID + pricing_policy_version: StrictInt = Field(ge=1) + pricing_policy_digest: Sha256 + uncached_input_nanodollars_per_token: Nanodollars + cached_input_nanodollars_per_token: Nanodollars + output_nanodollars_per_token: Nanodollars + + @model_validator(mode="after") + def nonzero_and_canonical(self) -> Self: + _require_uuid7(self.pricing_policy_id, "model pricing policy ID") + rates = ( + self.uncached_input_nanodollars_per_token, + self.cached_input_nanodollars_per_token, + self.output_nanodollars_per_token, + ) + if max(rates) <= 0: + raise ValueError("model pricing rates cannot all be zero") + return self + + +class ProjectModelProfileSpec(ModelCallValue): + """Operator-owned provider profile bound to one project and registry entry.""" + + project_id: UUID + profile_version: StrictInt = Field(ge=1) + registry_entry_id: UUID + registry_entry_digest: Sha256 + provider: ProviderIdentifier + api: ProviderIdentifier + model_snapshot: ProviderIdentifier + settings: ModelGenerationSettings + maximum_request_bytes: StrictInt = Field(ge=1, le=1_048_576) + maximum_output_bytes: StrictInt = Field(ge=1, le=1_048_576) + maximum_response_bytes: StrictInt = Field(ge=1, le=1_048_576) + maximum_input_tokens: StrictInt = Field(ge=1, le=10_000_000) + maximum_output_tokens: StrictInt = Field(ge=1, le=10_000_000) + deadline_ms: StrictInt = Field(ge=100, le=3_600_000) + call_slots: Literal[1] = 1 + pricing: ModelPricing + + @model_validator(mode="after") + def canonical_ids(self) -> Self: + _require_uuid7(self.registry_entry_id, "external checker registry entry ID") + if (self.provider, self.api) != ("openai_responses", "responses.v1"): + raise ValueError("model provider and API are unsupported") + if self.maximum_response_bytes < 6 * self.maximum_output_bytes + 4096: + raise ValueError("model response evidence cannot preserve the maximum output") + return self + + @property + def maximum_reservation_nanodollars(self) -> int: + """Reserve the maximum rate for each bounded input/output token class.""" + input_rate = max( + self.pricing.uncached_input_nanodollars_per_token, + self.pricing.cached_input_nanodollars_per_token, + ) + value = ( + self.maximum_input_tokens * input_rate + + self.maximum_output_tokens * self.pricing.output_nanodollars_per_token + ) + if value <= 0 or value > 9_000_000_000_000_000_000: + raise ValueError("model profile reservation is outside the supported range") + return value + + +class ModelProfileProvisioningFacts(ModelCallValue): + """Complete immutable profile publication facts consumed by Operator authority.""" + + operation_id: UUID + actor_profile_id: UUID + spec: ProjectModelProfileSpec + profile_digest: Sha256 + + @model_validator(mode="after") + def exact_digest(self) -> Self: + if self.profile_digest != canonical_json_hash(self.spec.model_dump(mode="json")): + raise ValueError("model profile digest mismatch") + return self + + +class ProjectModelProfile(ModelProfileProvisioningFacts): + """Published exact profile with its immutable authorization receipt.""" + + profile_id: UUID + authorization_evidence_id: UUID + created_at: AwareDatetime + + @model_validator(mode="after") + def canonical_record_id(self) -> Self: + _require_uuid7(self.profile_id, "model profile ID") + return self + + @property + def selector(self) -> ModelProfileSelector: + return ModelProfileSelector( + profile_id=self.profile_id, + profile_version=self.spec.profile_version, + profile_digest=self.profile_digest, + ) + + +class ProjectModelBudgetConfigurationSpec(ModelCallValue): + """One immutable lifetime-ceiling adjustment; versions never reset counters.""" + + project_id: UUID + configuration_version: StrictInt = Field(ge=1) + lifetime_limit_nanodollars: Nanodollars + + @model_validator(mode="after") + def positive_limit(self) -> Self: + if self.lifetime_limit_nanodollars <= 0: + raise ValueError("model budget limit must be positive") + return self + + +class ModelBudgetProvisioningFacts(ModelCallValue): + """Operator mutation facts for one immutable budget configuration version.""" + + operation_id: UUID + actor_profile_id: UUID + spec: ProjectModelBudgetConfigurationSpec + configuration_digest: Sha256 + + @model_validator(mode="after") + def exact_digest(self) -> Self: + if self.configuration_digest != canonical_json_hash(self.spec.model_dump(mode="json")): + raise ValueError("model budget configuration digest mismatch") + return self + + +class ProjectModelBudgetConfiguration(ModelBudgetProvisioningFacts): + """Published budget version plus lifetime counters retained on its project account.""" + + configuration_id: UUID + authorization_evidence_id: UUID + created_at: AwareDatetime + lifetime_reserved_nanodollars: Nanodollars + lifetime_settled_nanodollars: Nanodollars + + @model_validator(mode="after") + def canonical_record_id(self) -> Self: + _require_uuid7(self.configuration_id, "model budget configuration ID") + return self + + +class ModelPaidSlotKey(ModelCallValue): + """Paid-effect identity stable across checker leases and retry generations.""" + + project_id: UUID + assignment_id: UUID + submission_id: UUID + submission_version: StrictInt = Field(ge=1) + requirement_id: Identifier + registry_entry_digest: Sha256 + configuration_sha256: Sha256 + profile_id: UUID + profile_version: StrictInt = Field(ge=1) + profile_digest: Sha256 + slot_index: Literal[0] = 0 + + @model_validator(mode="after") + def canonical_profile(self) -> Self: + _require_uuid7(self.profile_id, "model profile ID") + return self + + @property + def digest(self) -> str: + return canonical_json_hash(self.model_dump(mode="json")) + + +class ModelCallExecutionFacts(ModelCallValue): + """Current run/lease facts used only for authority and currentness checks.""" + + project_id: UUID + task_id: UUID + assignment_id: UUID + submission_id: UUID + submission_version: StrictInt = Field(ge=1) + evaluation_request_id: UUID + evaluation_generation: StrictInt = Field(ge=1) + checker_run_id: UUID + worker_lease_id: UUID + worker_lease_generation: StrictInt = Field(ge=1) + worker_lease_expires_at: AwareDatetime + + +class ModelRecoveryChildRequest(ModelCallValue): + """Public Operator request for one exact known-terminal paid-call child.""" + + execution: ModelCallExecutionFacts + slot: ModelPaidSlotKey + registry_entry_id: UUID + terminal_digest: Sha256 + request_sha256: Sha256 + + @model_validator(mode="after") + def exact_execution(self) -> Self: + _require_uuid7(self.registry_entry_id, "external checker registry entry ID") + if ( + self.execution.project_id, + self.execution.assignment_id, + self.execution.submission_id, + self.execution.submission_version, + ) != ( + self.slot.project_id, + self.slot.assignment_id, + self.slot.submission_id, + self.slot.submission_version, + ): + raise ValueError("model recovery execution and slot do not match") + return self + + +class ModelCallEvidence(ModelCallValue): + """Verified private ART identity; transcript bytes never cross this contract.""" + + binding_id: UUID + content_id: UUID + put_attempt_id: UUID + verification_receipt_id: UUID + content_sha256: Sha256 + byte_count: StrictInt = Field(ge=1, le=1_048_576) + media_type: Literal["application/json"] = "application/json" + + +class ModelCallEffectAuthorityFacts(ModelCallValue): + """Exact paid effect identity supplied to AUTH for lease-independent observations.""" + + project_id: UUID + task_id: UUID + slot_id: UUID + slot_key: ModelPaidSlotKey + attempt_id: UUID + internal_call_request_id: UUID + + +class AttachedModelCallAttempt(ModelCallValue): + """Existing stable effect attached to a fresh checker invocation.""" + + slot_id: UUID + slot_key: ModelPaidSlotKey + attempt_id: UUID + attempt_number: StrictInt = Field(ge=1) + internal_call_request_id: UUID + request_sha256: Sha256 + state: Literal[ + "prepared", "dispatching", "uncertain", "completed", "known_failed", "known_no_effect" + ] + replayed: bool + + +class PreparedModelCallAttempt(AttachedModelCallAttempt): + """Server-owned attempt identity created before ART request evidence exists.""" + + state: Literal["prepared"] = "prepared" + + +class ModelCallUsage(ModelCallValue): + """Provider-observed token classes used for exact integer settlement.""" + + input_tokens: StrictInt = Field(ge=0) + cached_input_tokens: StrictInt = Field(ge=0) + output_tokens: StrictInt = Field(ge=0) + reasoning_tokens: StrictInt = Field(ge=0) + + @model_validator(mode="after") + def nested_classes(self) -> Self: + if ( + self.cached_input_tokens > self.input_tokens + or self.reasoning_tokens > self.output_tokens + ): + raise ValueError("model usage token classes are inconsistent") + return self + + +class ModelDispatchReservation(ModelCallValue): + """Committed dispatch intent and lifetime project-budget reservation.""" + + slot_id: UUID + attempt_id: UUID + attempt_number: StrictInt = Field(ge=1) + internal_call_request_id: UUID + effect: ModelCallEffectAuthorityFacts + request_sha256: Sha256 + request_evidence: ModelCallEvidence + budget_configuration_id: UUID + budget_configuration_version: StrictInt = Field(ge=1) + budget_configuration_digest: Sha256 + reserved_nanodollars: Nanodollars + state: Literal["dispatching", "uncertain", "completed"] + provider_response_id: ProviderResponseIdentifier | None = None + response_evidence: ModelCallEvidence | None = None + replayed: bool + + +class ModelCallRecoveryAttachment(ModelCallValue): + """Fresh authorized recovery access to one known provider effect.""" + + slot_id: UUID + attempt_id: UUID + attempt_number: StrictInt = Field(ge=1) + internal_call_request_id: UUID + provider_response_id: ProviderResponseIdentifier + state: Literal["dispatching", "uncertain"] + replayed: bool + + +class CompletedModelCall(ModelCallValue): + """Exact immutable response evidence and once-settled project cost.""" + + slot_id: UUID + attempt_id: UUID + attempt_number: StrictInt = Field(ge=1) + internal_call_request_id: UUID + provider_response_id: ProviderResponseIdentifier + request_evidence: ModelCallEvidence + response_evidence: ModelCallEvidence + usage: ModelCallUsage + settled_nanodollars: Nanodollars + completed_at: AwareDatetime + replayed: bool + + +class KnownFailedModelCall(ModelCallValue): + """Authenticated terminal provider effect without a usable model response.""" + + slot_id: UUID + attempt_id: UUID + attempt_number: StrictInt = Field(ge=1) + internal_call_request_id: UUID + provider_response_id: ProviderResponseIdentifier + request_evidence: ModelCallEvidence + response_evidence: ModelCallEvidence + usage: ModelCallUsage + settled_nanodollars: Nanodollars + completed_at: AwareDatetime + replayed: bool + + +class KnownNoEffectProof(ModelCallValue): + """Exact provider proof that an attempted create caused no billable effect.""" + + proof_id: UUID + proof_digest: Sha256 + observed_at: AwareDatetime + + +def model_retry_terminal_digest( + terminal: KnownFailedModelCall | KnownNoEffectProof, +) -> str: + """Commit the exact terminal facts that may authorize a paid child attempt.""" + if isinstance(terminal, KnownNoEffectProof): + facts = { + "state": "known_no_effect", + "proof_id": str(terminal.proof_id), + "proof_digest": terminal.proof_digest, + "observed_at": terminal.observed_at.isoformat(), + } + else: + facts = { + "state": "known_failed", + "provider_response_id": terminal.provider_response_id, + "response_content_sha256": terminal.response_evidence.content_sha256, + "usage": terminal.usage.model_dump(mode="json"), + "settled_nanodollars": terminal.settled_nanodollars, + } + return canonical_json_hash(facts) + + +class ModelActionEvidence(ModelCallValue): + """Opaque exact AUTH decision returned to the model-call owner.""" + + evidence_id: UUID + facts_digest: Sha256 + + +class ModelProfileReaderPort(Protocol): + """Read one immutable profile by complete identity.""" + + async def read_exact( + self, + selector: ModelProfileSelector, + *, + project_id: UUID, + registry_entry_id: UUID, + registry_entry_digest: str, + ) -> ProjectModelProfile: ... + + +class ModelCallCurrentExecutionPort(Protocol): + """Require one current authorized checker execution without granting model effects.""" + + async def require_current( + self, facts: ModelCallExecutionFacts, slot: ModelPaidSlotKey + ) -> None: ... + + +class ModelCallEvidencePort(Protocol): + """Verify ART evidence belongs to the exact call attempt and role.""" + + async def require_request( + self, attempt: PreparedModelCallAttempt, evidence: ModelCallEvidence + ) -> None: ... + + async def require_response(self, attempt_id: UUID, evidence: ModelCallEvidence) -> None: ... + + +class ModelCallAuthorityPort(Protocol): + """Action-specific authority; implementations must reject phase substitution.""" + + async def authorize_profile( + self, facts: ModelProfileProvisioningFacts + ) -> ModelActionEvidence: ... + + async def authorize_budget( + self, facts: ModelBudgetProvisioningFacts + ) -> ModelActionEvidence: ... + + async def authorize_prepare( + self, + execution: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + request_sha256: str, + ) -> ModelActionEvidence: ... + + async def authorize_dispatch( + self, + execution: ModelCallExecutionFacts, + attempt: PreparedModelCallAttempt, + request_evidence: ModelCallEvidence, + ) -> ModelActionEvidence: ... + + async def authorize_recover( + self, + execution: ModelCallExecutionFacts, + effect: ModelCallEffectAuthorityFacts, + ) -> ModelActionEvidence: ... + + async def authorize_observe( + self, + effect: ModelCallEffectAuthorityFacts, + provider_response_id: str | None, + observation_digest: str, + ) -> ModelActionEvidence: ... + + async def authorize_child( + self, + execution: ModelCallExecutionFacts, + parent_attempt_id: UUID, + terminal_digest: str, + ) -> ModelActionEvidence: ... + + async def authorize_complete( + self, + effect: ModelCallEffectAuthorityFacts, + response_evidence: ModelCallEvidence | None, + terminal_digest: str, + ) -> ModelActionEvidence: ... + + +def model_usage_cost(profile: ProjectModelProfileSpec, usage: ModelCallUsage) -> int: + """Price disjoint token classes using exact integer nano-USD arithmetic.""" + usage = ModelCallUsage.model_validate(usage) + if ( + usage.input_tokens > profile.maximum_input_tokens + or usage.output_tokens > profile.maximum_output_tokens + ): + raise ModelCallConflict("model usage exceeds the approved profile") + uncached = usage.input_tokens - usage.cached_input_tokens + pricing = profile.pricing + value = ( + uncached * pricing.uncached_input_nanodollars_per_token + + usage.cached_input_tokens * pricing.cached_input_nanodollars_per_token + + usage.output_tokens * pricing.output_nanodollars_per_token + ) + if value < 0 or value > profile.maximum_reservation_nanodollars: + raise ModelCallConflict("model usage cost exceeds the approved reservation") + return value + + +def model_action_digest(action: str, facts: object) -> str: + """Commit one action name and its complete typed facts for AUTH evidence.""" + if isinstance(facts, BaseModel): + value = facts.model_dump(mode="json") + else: + value = facts + return canonical_json_hash({"action": action, "facts": value}) + + +__all__ = ( + "CompletedModelCall", + "AttachedModelCallAttempt", + "KnownNoEffectProof", + "KnownFailedModelCall", + "ModelActionEvidence", + "ModelBudgetProvisioningFacts", + "ModelCallAuthorityPort", + "ModelCallBudgetUnavailable", + "ModelCallConflict", + "ModelCallCurrentExecutionPort", + "ModelCallEvidence", + "ModelCallEffectAuthorityFacts", + "ModelCallEvidencePort", + "ModelCallExecutionFacts", + "ModelCallUnavailable", + "ModelCallUsage", + "ModelCallRecoveryAttachment", + "ModelDispatchReservation", + "ModelGenerationSettings", + "ModelPaidSlotKey", + "ModelPricing", + "ModelProfileProvisioningFacts", + "ModelRecoveryChildRequest", + "ModelProfileReaderPort", + "ModelProfileSelector", + "PreparedModelCallAttempt", + "ProjectModelBudgetConfiguration", + "ProjectModelBudgetConfigurationSpec", + "ProjectModelProfile", + "ProjectModelProfileSpec", + "model_usage_cost", + "model_action_digest", + "model_retry_terminal_digest", +) diff --git a/backend/app/modules/checkers/api/model_grants.py b/backend/app/modules/checkers/api/model_grants.py new file mode 100644 index 000000000..3058e81e9 --- /dev/null +++ b/backend/app/modules/checkers/api/model_grants.py @@ -0,0 +1,32 @@ +"""Private invocation grants linking isolated project images to the model proxy.""" + +from dataclasses import dataclass, field +from typing import Protocol +from uuid import UUID + +from app.modules.checkers.api.model_calls import ( + ModelActionEvidence, + ModelCallExecutionFacts, + ModelPaidSlotKey, +) + + +class ModelInvocationGrantAuthorityPort(Protocol): + """Issue one bounded callback capability without authorizing provider spend.""" + + async def authorize_grant( + self, execution: ModelCallExecutionFacts, slot: ModelPaidSlotKey, + external_request_digest: str, token_hash: str, + ) -> ModelActionEvidence: + """Authorize the exact invocation, request binding and opaque token hash.""" + + +@dataclass(frozen=True, slots=True) +class ModelInvocationGrantFacts: + """Immutable owner facts selected privately by the proxy, never image input.""" + + token_hash: str = field(repr=False) + external_request_digest: str + execution: ModelCallExecutionFacts + slot: ModelPaidSlotKey + registry_entry_id: UUID diff --git a/backend/app/modules/checkers/api/model_profiles.py b/backend/app/modules/checkers/api/model_profiles.py new file mode 100644 index 000000000..1b6b941cf --- /dev/null +++ b/backend/app/modules/checkers/api/model_profiles.py @@ -0,0 +1,102 @@ +"""Reserved external-checker configuration for exact project model profiles.""" + +from __future__ import annotations + +from collections.abc import Mapping +from uuid import UUID + +from pydantic import JsonValue, ValidationError + +from app.modules.checkers.api.external import ( + ExternalCheckerPhase, + ExternalCheckerRegistryEntry, +) +from app.modules.checkers.api.model_calls import ( + ModelCallUnavailable, + ModelProfileReaderPort, + ModelProfileSelector, +) + + +MODEL_PROFILE_CONFIGURATION_KEYS = frozenset( + {"model_profile_id", "model_profile_version", "model_profile_digest"} +) + + +class ModelProfileConfigurationError(ValueError): + """Reject partial, phase-substituted or unavailable profile selection.""" + + +def configured_model_profile_selector( + configuration: Mapping[str, JsonValue], + *, + stage: ExternalCheckerPhase, +) -> ModelProfileSelector | None: + """Decode the reserved all-or-none selector without affecting ordinary images.""" + present = MODEL_PROFILE_CONFIGURATION_KEYS.intersection(configuration) + if not present: + return None + if present != MODEL_PROFILE_CONFIGURATION_KEYS or stage != "post_submit": + raise ModelProfileConfigurationError("model profile configuration is invalid") + profile_id = configuration["model_profile_id"] + profile_version = configuration["model_profile_version"] + profile_digest = configuration["model_profile_digest"] + if ( + type(profile_id) is not str + or type(profile_version) is not int + or type(profile_digest) is not str + ): + raise ModelProfileConfigurationError("model profile configuration is invalid") + try: + canonical_profile_id = UUID(profile_id) + if str(canonical_profile_id) != profile_id: + raise ValueError("model profile ID is not canonical") + return ModelProfileSelector( + profile_id=canonical_profile_id, + profile_version=profile_version, + profile_digest=profile_digest, + ) + except (ValueError, ValidationError) as exc: + raise ModelProfileConfigurationError("model profile configuration is invalid") from exc + + +async def require_model_profile_configuration( + configuration: Mapping[str, JsonValue], + *, + stage: ExternalCheckerPhase, + project_id: UUID, + registry: ExternalCheckerRegistryEntry, + profiles: ModelProfileReaderPort | None, +) -> ModelProfileSelector | None: + """Resolve a model-enabled binding to one exact immutable approved profile.""" + selector = configured_model_profile_selector(configuration, stage=stage) + if selector is None: + return None + if profiles is None: + raise ModelProfileConfigurationError("model profile reader is unavailable") + try: + profile = await profiles.read_exact( + selector, + project_id=project_id, + registry_entry_id=registry.registry_entry_id, + registry_entry_digest=registry.entry_digest, + ) + except ModelCallUnavailable as exc: + # Registry/persistence details remain concealed at the policy boundary. + raise ModelProfileConfigurationError("model profile is unavailable") from exc + if ( + profile.selector != selector + or profile.spec.project_id != project_id + or profile.spec.registry_entry_id != registry.registry_entry_id + or profile.spec.registry_entry_digest != registry.entry_digest + ): + raise ModelProfileConfigurationError("model profile is unavailable") + return selector + + +__all__ = ( + "MODEL_PROFILE_CONFIGURATION_KEYS", + "ModelProfileConfigurationError", + "configured_model_profile_selector", + "require_model_profile_configuration", +) diff --git a/backend/app/modules/checkers/api/output_custody.py b/backend/app/modules/checkers/api/output_custody.py index c0a8fcc9b..d7c156011 100644 --- a/backend/app/modules/checkers/api/output_custody.py +++ b/backend/app/modules/checkers/api/output_custody.py @@ -1,4 +1,4 @@ -"""CHECKERS-owned output custody contracts; production reservations are unavailable.""" +"""Call-stable private model evidence through CHECKERS and existing ART custody.""" from collections.abc import AsyncIterable from dataclasses import dataclass @@ -7,12 +7,14 @@ from pydantic import Field, StrictInt, model_validator -from app.modules.checkers.api.evaluation_contract import PostSubmissionExecutionRequest +from app.modules.checkers.api.model_calls import ( + ModelCallExecutionFacts, + ModelPaidSlotKey, + ModelCallEvidence, +) from app.modules.checkers.api.canonical_values import ( CheckerValue, - Identifier, ResourceId, - VersionNumber, ) @@ -21,59 +23,69 @@ class CheckerOutputUnavailable(RuntimeError): class CheckerOutputSelector(CheckerValue): - """Select exact immutable evaluation and the caller's claimed execution lease.""" + """Select one call effect's bytes without tying late completion to an old run.""" - evaluation: PostSubmissionExecutionRequest - checker_run_id: ResourceId - worker_lease_id: ResourceId - worker_lease_generation: VersionNumber - slot_key: Identifier + paid_slot: ModelPaidSlotKey + task_id: ResourceId + model_call_slot_id: ResourceId + model_call_attempt_id: ResourceId + slot_key: Literal["model_request", "model_response"] + content_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + execution: ModelCallExecutionFacts | None = None + + @model_validator(mode="after") + def exact_execution_scope(self) -> Self: + """Request writes need a current invocation; effect responses do not.""" + if self.slot_key == "model_response": + if self.execution is not None: + raise ValueError("model response custody is call-effect scoped") + return self + execution = self.execution + if execution is None or ( + execution.project_id, + execution.task_id, + execution.assignment_id, + execution.submission_id, + execution.submission_version, + ) != ( + self.paid_slot.project_id, + self.task_id, + self.paid_slot.assignment_id, + self.paid_slot.submission_id, + self.paid_slot.submission_version, + ): + raise ValueError("model request custody requires exact execution facts") + return self class CheckerOutputSlot(CheckerValue): - """One globally unique run slot issued by CHECKERS, not by a contributor.""" + """One bounded canonical JSON role issued by the call-effect owner.""" - key: Identifier - media_type: Literal["application/json", "application/octet-stream", "text/plain"] - maximum_bytes: Annotated[StrictInt, Field(ge=1, le=536_870_912)] + key: Literal["model_request", "model_response"] + media_type: Literal["application/json"] = "application/json" + maximum_bytes: Annotated[StrictInt, Field(ge=1, le=1_048_576)] class CheckerOutputReservation(CheckerValue): """Detached owner facts, never a substitute for fresh action authority.""" - evaluation: PostSubmissionExecutionRequest - checker_run_id: ResourceId - worker_lease_id: ResourceId - worker_lease_generation: VersionNumber - slots: tuple[CheckerOutputSlot, ...] = Field(max_length=64) + selector: CheckerOutputSelector + slot: CheckerOutputSlot @model_validator(mode="after") - def unique_slots(self) -> Self: - """Keep generic ART binding's run/role scope unambiguous.""" - if len({slot.key for slot in self.slots}) != len(self.slots): - raise ValueError("checker output slots repeat") + def exact_role(self) -> Self: + """Reserve exactly the requested role; never grant another evidence slot.""" + if self.slot.key != self.selector.slot_key: + raise ValueError("checker output role differs from reservation") return self def select(self, selector: CheckerOutputSelector) -> CheckerOutputSlot: - """Match the caller's claimed lease as well as immutable evaluation facts.""" + """Match exact call custody and the authority phase selected by its owner.""" current = CheckerOutputReservation.model_validate(self) selector = CheckerOutputSelector.model_validate(selector) - if ( - current.evaluation, - current.checker_run_id, - current.worker_lease_id, - current.worker_lease_generation, - ) != ( - selector.evaluation, - selector.checker_run_id, - selector.worker_lease_id, - selector.worker_lease_generation, - ): + if current.selector != selector: raise CheckerOutputUnavailable("checker_output_reservation_unavailable") - for slot in current.slots: - if slot.key == selector.slot_key: - return slot - raise CheckerOutputUnavailable("checker_output_slot_unavailable") + return current.slot class CheckerOutputReservationPort(Protocol): @@ -83,6 +95,13 @@ async def resolve(self, selector: CheckerOutputSelector) -> CheckerOutputReserva """Return exact current facts under the caller's CHECKERS transaction locks.""" +class CheckerOutputTaskCustodyPort(Protocol): + """Retain historical TASK ancestry before call locks or ART foreign-key writes.""" + + async def lock_model_evidence_scope(self, selector: CheckerOutputSelector) -> None: + """Require exact owners without requiring the original run to remain active.""" + + @dataclass(frozen=True, slots=True) class CheckerOutputBindingRequest: """Bind one exact independently verified output in the caller transaction.""" @@ -134,12 +153,15 @@ async def bind_checker_output( class CheckerArtifactOutputPort(Protocol): """Store generated output for one fixed checker execution.""" - async def store( - self, request: CheckerOutputArtifactRequest - ) -> CheckerOutputArtifactResult: + async def store(self, request: CheckerOutputArtifactRequest) -> CheckerOutputArtifactResult: """Store one generated checker artifact.""" - async def recover( - self, selector: CheckerOutputSelector - ) -> CheckerOutputArtifactResult | None: + async def recover(self, selector: CheckerOutputSelector) -> CheckerOutputArtifactResult | None: """Recover a lost output response without a byte source or regeneration.""" + + async def read_response( + self, + selector: CheckerOutputSelector, + evidence: ModelCallEvidence, + ) -> bytes: + """Read one authorized private response with exact byte/hash verification.""" diff --git a/backend/app/modules/checkers/execution_coordination.py b/backend/app/modules/checkers/execution_coordination.py index f4b72693d..70355d108 100644 --- a/backend/app/modules/checkers/execution_coordination.py +++ b/backend/app/modules/checkers/execution_coordination.py @@ -31,11 +31,6 @@ from app.modules.checkers.api.selected_post_submit import ( SelectedPostSubmitCurrentResultReference, ) -from app.modules.checkers.api.output_custody import ( - CheckerOutputReservation, - CheckerOutputSelector, - CheckerOutputUnavailable, -) from app.modules.checkers.execution_repository import ( ExecutionRepository, require_transaction, @@ -411,36 +406,3 @@ async def require_current_execution(self, facts): raise CheckerExecutionUnavailable("checker_current_execution_unavailable") execution_authority_digest(facts) await ExecutionRepository(self._session).require_lease(facts.request, facts.lease) - - -class CheckerOutputReservations: - """Selected evidence-only checker executions have no artifact output slots.""" - - def __init__(self, session: AsyncSession): - """Resolve output custody within the supplied CHECKERS session.""" - self._session = session - - async def resolve(self, selector: CheckerOutputSelector) -> CheckerOutputReservation: - """Require exact current lease custody and return the supported zero-output contract.""" - from app.modules.checkers.api.execution import CheckerExecutionUnavailable - - try: - repo = ExecutionRepository(self._session) - run = await repo.lock_current(selector.evaluation) - if ( - run.status != "running" - or run.id != str(selector.checker_run_id) - or run.worker_lease_id != str(selector.worker_lease_id) - or run.worker_lease_generation != selector.worker_lease_generation - or run.worker_lease_expires_at <= await repo.now() - ): - raise CheckerOutputUnavailable("checker_output_reservation_unavailable") - return CheckerOutputReservation( - evaluation=selector.evaluation, - checker_run_id=UUID(run.id), - worker_lease_id=UUID(run.worker_lease_id), - worker_lease_generation=run.worker_lease_generation, - slots=(), - ) - except CheckerExecutionUnavailable: - raise CheckerOutputUnavailable("checker_output_reservation_unavailable") from None diff --git a/backend/app/modules/checkers/model_calls/__init__.py b/backend/app/modules/checkers/model_calls/__init__.py new file mode 100644 index 000000000..ef605b5a6 --- /dev/null +++ b/backend/app/modules/checkers/model_calls/__init__.py @@ -0,0 +1,5 @@ +"""Durable paid model-call ownership for external project checker images.""" + +from app.modules.checkers.model_calls.service import ModelCallService + +__all__ = ("ModelCallService",) diff --git a/backend/app/modules/checkers/model_calls/custody.py b/backend/app/modules/checkers/model_calls/custody.py new file mode 100644 index 000000000..d1aef0e38 --- /dev/null +++ b/backend/app/modules/checkers/model_calls/custody.py @@ -0,0 +1,159 @@ +"""Current invocation and call-stable private evidence custody.""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.checkers.api.execution import ( + CheckerExecutionUnavailable, + EvaluationTaskGuard, + ExecutionLease, +) +from app.modules.checkers.api.model_calls import ( + ModelCallCurrentExecutionPort, + ModelCallExecutionFacts, + ModelCallUnavailable, + ModelPaidSlotKey, +) +from app.modules.checkers.api.output_custody import ( + CheckerOutputReservation, + CheckerOutputSelector, + CheckerOutputSlot, + CheckerOutputTaskCustodyPort, + CheckerOutputUnavailable, +) +from app.modules.checkers.execution_repository import ( + ExecutionRepository, + require_transaction, + reservation, + stored_request, +) +from app.modules.checkers.models import CheckerRun +from app.modules.checkers.model_calls.repository import ModelCallRepository + + +class ModelCallCurrentExecution: + """Check the exact locked project binding before any paid effect is reserved.""" + + def __init__(self, session: AsyncSession, *, tasks: EvaluationTaskGuard): + self._session, self._tasks = session, tasks + + async def require_current( + self, + facts: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + ) -> None: + """Read immutable request facts, then lock TASK before the CHECKERS fence.""" + require_transaction(self._session) + facts = ModelCallExecutionFacts.model_validate(facts) + slot = ModelPaidSlotKey.model_validate(slot) + run = await self._session.scalar( + select(CheckerRun) + .where( + CheckerRun.id == str(facts.checker_run_id), + CheckerRun.project_id == str(facts.project_id), + CheckerRun.task_id == str(facts.task_id), + CheckerRun.submission_id == str(facts.submission_id), + CheckerRun.submission_version == facts.submission_version, + CheckerRun.evaluation_request_id == str(facts.evaluation_request_id), + CheckerRun.evaluation_generation == facts.evaluation_generation, + ) + .execution_options(populate_existing=True) + ) + if run is None: + raise ModelCallUnavailable("model_call_execution_unavailable") + request = stored_request(run) + if request.assignment_id != facts.assignment_id or ( + slot.project_id, + slot.assignment_id, + slot.submission_id, + slot.submission_version, + ) != (facts.project_id, facts.assignment_id, facts.submission_id, facts.submission_version): + raise ModelCallUnavailable("model_call_execution_unavailable") + bindings = [ + binding + for binding in request.policy.entries + if binding.requirement_id == slot.requirement_id + and binding.registry.entry_digest == slot.registry_entry_digest + and binding.configuration_sha256 == slot.configuration_sha256 + ] + if len(bindings) != 1: + raise ModelCallUnavailable("model_call_binding_unavailable") + configuration = bindings[0].configuration + if ( + configuration.get("model_profile_id") != str(slot.profile_id) + or type(configuration.get("model_profile_version")) is not int + or configuration.get("model_profile_version") != slot.profile_version + or configuration.get("model_profile_digest") != slot.profile_digest + ): + raise ModelCallUnavailable("model_call_profile_unavailable") + try: + if not await self._tasks.lock_evaluation_scope(request): + raise ModelCallUnavailable("model_call_execution_unavailable") + await ExecutionRepository(self._session).require_lease( + request, + ExecutionLease( + reservation=reservation(run), + lease_id=facts.worker_lease_id, + lease_generation=facts.worker_lease_generation, + expires_at=facts.worker_lease_expires_at, + ), + ) + except CheckerExecutionUnavailable: + raise ModelCallUnavailable("model_call_execution_unavailable") from None + + +class CheckerOutputReservations: + """Resolve two bounded ART roles under one immutable paid call identity.""" + + def __init__( + self, + session: AsyncSession, + *, + current: ModelCallCurrentExecutionPort, + tasks: CheckerOutputTaskCustodyPort, + ): + self._session, self._current = session, current + self._tasks = tasks + + async def resolve(self, selector: CheckerOutputSelector) -> CheckerOutputReservation: + """Requests require current execution; late responses retain effect custody.""" + require_transaction(self._session) + selector = CheckerOutputSelector.model_validate(selector) + if selector.execution is not None: + await self._current.require_current(selector.execution, selector.paid_slot) + else: + await self._tasks.lock_model_evidence_scope(selector) + repo = ModelCallRepository(self._session) + await repo.lock_slot_scope(selector.paid_slot) + slot = await repo.slot(selector.paid_slot, lock=True) + if slot is None or ( + slot.id != str(selector.model_call_slot_id) or slot.task_id != str(selector.task_id) + ): + raise CheckerOutputUnavailable("checker_output_reservation_unavailable") + attempt = await repo.attempt(selector.model_call_attempt_id, lock=True) + if attempt is None or attempt.slot_id != slot.id: + raise CheckerOutputUnavailable("checker_output_reservation_unavailable") + profile = await repo.profile_exact( + selector.paid_slot.profile_id, + selector.paid_slot.profile_version, + selector.paid_slot.profile_digest, + ) + if profile is None or ( + profile.project_id != str(selector.paid_slot.project_id) + or profile.registry_entry_digest != selector.paid_slot.registry_entry_digest + ): + raise CheckerOutputUnavailable("checker_output_profile_unavailable") + if selector.slot_key == "model_request": + if attempt.request_sha256 != selector.content_sha256: + raise CheckerOutputUnavailable("checker_output_request_conflict") + maximum = profile.maximum_request_bytes + else: + if attempt.state not in {"dispatching", "uncertain", "completed", "known_failed"}: + raise CheckerOutputUnavailable("checker_output_effect_unavailable") + if attempt.response_content_sha256 not in {None, selector.content_sha256}: + raise CheckerOutputUnavailable("checker_output_response_conflict") + maximum = profile.maximum_response_bytes + return CheckerOutputReservation( + selector=selector, + slot=CheckerOutputSlot(key=selector.slot_key, maximum_bytes=maximum), + ) diff --git a/backend/app/modules/checkers/model_calls/grants.py b/backend/app/modules/checkers/model_calls/grants.py new file mode 100644 index 000000000..024efab8c --- /dev/null +++ b/backend/app/modules/checkers/model_calls/grants.py @@ -0,0 +1,270 @@ +"""One-use private callback grants; paid-effect identity remains in the call ledger.""" + +import hashlib +import json +import re +import secrets +from uuid import UUID + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey, String, Uuid, select, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime + +from app.core.identifiers import new_record_id +from app.db.base import Base +from app.interfaces.external_checker_execution import ExternalCheckerModelGrant +from app.modules.checkers.api.model_calls import ( + ModelCallCurrentExecutionPort, + ModelCallExecutionFacts, + ModelCallEffectAuthorityFacts, + ModelCallUnavailable, + ModelPaidSlotKey, + model_action_digest, +) +from app.modules.checkers.api.model_grants import ( + ModelInvocationGrantAuthorityPort, + ModelInvocationGrantFacts, +) +from app.modules.checkers.model_calls.repository import require_model_call_transaction + + +class CheckerModelInvocationGrant(Base): + """No plaintext capability or transcript; exact immutable callback custody only.""" + + __tablename__ = "checker_model_invocation_grants" + __table_args__ = ( + CheckConstraint( + "(get_byte(uuid_send(id),6)>>4)=7 and (get_byte(uuid_send(id),8)&192)=128", + name="id_uuid7", + ), + CheckConstraint( + "token_hash ~ '^sha256:[0-9a-f]{64}$' and " + "external_request_digest ~ '^sha256:[0-9a-f]{64}$' and " + "(selected_request_sha256 is null or selected_request_sha256 ~ '^sha256:[0-9a-f]{64}$')", + name="digests", + ), + CheckConstraint( + "(consumed_at is null and selected_request_sha256 is null and selected_attempt_id is null) or " + "(consumed_at is not null and selected_request_sha256 is not null and selected_attempt_id is not null)", + name="consumption", + ), + ) + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + token_hash: Mapped[str] = mapped_column(String(71), nullable=False, unique=True) + project_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("projects.id", ondelete="RESTRICT"), + nullable=False, + ) + checker_run_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("checker_runs.id", ondelete="RESTRICT"), + nullable=False, + ) + registry_entry_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("external_checker_registry_entries.id", ondelete="RESTRICT"), + nullable=False, + ) + execution_facts: Mapped[dict] = mapped_column(JSONB, nullable=False) + paid_slot: Mapped[dict] = mapped_column(JSONB, nullable=False) + external_request_digest: Mapped[str] = mapped_column(String(71), nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + issued_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.clock_timestamp(), + nullable=False, + ) + authorization_evidence_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("audit_events.id", ondelete="RESTRICT"), + nullable=False, + unique=True, + ) + selected_request_sha256: Mapped[str | None] = mapped_column(String(71)) + selected_attempt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("checker_model_call_attempts.id", ondelete="RESTRICT"), + ) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +def model_grant_token_hash(token: str) -> str: + """Hash a closed short-lived capability with an explicit domain separator.""" + if type(token) is not str or re.fullmatch(r"model_[0-9a-f]{32}", token) is None: + raise ModelCallUnavailable("model_grant_unavailable") + return ( + "sha256:" + + hashlib.sha256(b"workstream.checker.model-grant.v1\0" + token.encode("ascii")).hexdigest() + ) + + +class ModelInvocationGrants: + """Issue and consume invocation custody in caller-owned transactions.""" + + def __init__( + self, + session: AsyncSession, + *, + authority: ModelInvocationGrantAuthorityPort, + current: ModelCallCurrentExecutionPort, + ): + self._session, self._authority, self._current = session, authority, current + + async def issue( + self, + execution: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + external_request_digest: str, + *, + registry_entry_id: UUID, + ) -> ExternalCheckerModelGrant: + """Authorize current binding before creating one private random capability.""" + require_model_call_transaction(self._session) + execution = ModelCallExecutionFacts.model_validate(execution) + slot = ModelPaidSlotKey.model_validate(slot) + token = "model_" + secrets.token_hex(16) + grant = ExternalCheckerModelGrant(token, external_request_digest) + token_hash = model_grant_token_hash(token) + evidence = await self._authority.authorize_grant( + execution, + slot, + external_request_digest, + token_hash, + ) + facts = { + "execution": execution.model_dump(mode="json"), + "slot": slot.model_dump(mode="json"), + "external_request_digest": external_request_digest, + "token_hash": token_hash, + } + if evidence.facts_digest != model_action_digest("grant", facts): + raise ModelCallUnavailable("model_grant_authority_unavailable") + await self._current.require_current(execution, slot) + self._session.add( + CheckerModelInvocationGrant( + id=str(new_record_id()), + token_hash=token_hash, + project_id=str(execution.project_id), + checker_run_id=str(execution.checker_run_id), + registry_entry_id=str(registry_entry_id), + execution_facts=execution.model_dump(mode="json"), + paid_slot=slot.model_dump(mode="json"), + external_request_digest=external_request_digest, + expires_at=execution.worker_lease_expires_at, + authorization_evidence_id=str(evidence.evidence_id), + ) + ) + await self._session.flush() + return grant + + async def resolve(self, token: str, request_digest: str) -> ModelInvocationGrantFacts: + """Select concealed capability facts; this lookup does not grant model authority.""" + require_model_call_transaction(self._session) + token_hash = model_grant_token_hash(token) + row = await self._session.scalar( + select(CheckerModelInvocationGrant).where( + CheckerModelInvocationGrant.token_hash == token_hash, + CheckerModelInvocationGrant.external_request_digest == request_digest, + CheckerModelInvocationGrant.consumed_at.is_(None), + CheckerModelInvocationGrant.expires_at > func.clock_timestamp(), + ) + ) + if row is None: + raise ModelCallUnavailable("model_grant_unavailable") + execution = ModelCallExecutionFacts.model_validate_json(json.dumps(row.execution_facts)) + slot = ModelPaidSlotKey.model_validate_json(json.dumps(row.paid_slot)) + if ( + str(execution.project_id), + str(execution.checker_run_id), + execution.worker_lease_expires_at, + ) != ( + row.project_id, + row.checker_run_id, + row.expires_at, + ): + raise ModelCallUnavailable("model_grant_unavailable") + return ModelInvocationGrantFacts( + token_hash, request_digest, execution, slot, UUID(row.registry_entry_id) + ) + + async def consume_prepared( + self, + facts: ModelInvocationGrantFacts, + *, + request_sha256: str, + prepared_attempt_id: UUID, + ) -> None: + """Consume after ledger preparation in the same AUTH/TASK/slot transaction.""" + from app.modules.checkers.model_calls.repository import ModelCallRepository + + require_model_call_transaction(self._session) + if re.fullmatch(r"sha256:[0-9a-f]{64}", request_sha256) is None: + raise ModelCallUnavailable("model_grant_unavailable") + repo = ModelCallRepository(self._session) + slot = await repo.slot(facts.slot, lock=True) + attempt = await repo.attempt(prepared_attempt_id, lock=True) + if ( + slot is None + or attempt is None + or (attempt.slot_id != slot.id or attempt.request_sha256 != request_sha256) + ): + raise ModelCallUnavailable("model_grant_unavailable") + row = await self._session.scalar( + select(CheckerModelInvocationGrant) + .where( + CheckerModelInvocationGrant.token_hash == facts.token_hash, + CheckerModelInvocationGrant.external_request_digest + == facts.external_request_digest, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + now = await self._session.scalar(select(func.clock_timestamp())) + if ( + row is None + or row.consumed_at is not None + or row.expires_at <= now + or ( + row.execution_facts != facts.execution.model_dump(mode="json") + or row.paid_slot != facts.slot.model_dump(mode="json") + ) + ): + raise ModelCallUnavailable("model_grant_unavailable") + row.selected_request_sha256, row.consumed_at = request_sha256, now + row.selected_attempt_id = str(prepared_attempt_id) + await self._session.flush() + + async def completed_effect( + self, + token: str, + request_digest: str, + execution: ModelCallExecutionFacts, + ) -> ModelCallEffectAuthorityFacts: + """Select exact consumed effect facts; the ledger must freshly authorize their read.""" + from app.modules.checkers.model_calls.models import CheckerModelCallAttempt + + require_model_call_transaction(self._session) + row = await self._session.scalar( + select(CheckerModelInvocationGrant).where( + CheckerModelInvocationGrant.token_hash == model_grant_token_hash(token), + CheckerModelInvocationGrant.external_request_digest == request_digest, + CheckerModelInvocationGrant.consumed_at.is_not(None), + ) + ) + if row is None or row.execution_facts != execution.model_dump(mode="json"): + raise ModelCallUnavailable("model_grant_completion_unavailable") + attempt = await self._session.get(CheckerModelCallAttempt, row.selected_attempt_id) + if attempt is None or attempt.request_sha256 != row.selected_request_sha256: + raise ModelCallUnavailable("model_grant_completion_unavailable") + slot = ModelPaidSlotKey.model_validate_json(json.dumps(row.paid_slot)) + return ModelCallEffectAuthorityFacts( + project_id=execution.project_id, + task_id=execution.task_id, + slot_id=UUID(attempt.slot_id), + slot_key=slot, + attempt_id=UUID(attempt.id), + internal_call_request_id=UUID(attempt.internal_call_request_id), + ) diff --git a/backend/app/modules/checkers/model_calls/models.py b/backend/app/modules/checkers/model_calls/models.py new file mode 100644 index 000000000..7027b0c93 --- /dev/null +++ b/backend/app/modules/checkers/model_calls/models.py @@ -0,0 +1,450 @@ +"""SQLAlchemy rows for immutable profiles, lifetime budgets and paid call slots.""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + DateTime, + ForeignKey, + ForeignKeyConstraint, + Integer, + String, + UniqueConstraint, + Uuid, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql.expression import text + +from app.db.base import Base + + +_UUID7 = "(get_byte(uuid_send(id), 6) >> 4) = 7 and (get_byte(uuid_send(id), 8) & 192) = 128" +_SHA = "^sha256:[0-9a-f]{64}$" + + +class CheckerModelProfile(Base): + """One immutable Operator-authorized provider/pricing profile.""" + + __tablename__ = "checker_model_profiles" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint("project_id", "profile_version", name="checker_model_profiles_version"), + UniqueConstraint( + "id", + "profile_version", + "profile_digest", + name="checker_model_profiles_identity", + ), + UniqueConstraint("profile_digest", name="checker_model_profiles_digest"), + UniqueConstraint("operation_id", name="checker_model_profiles_operation"), + UniqueConstraint("authorization_evidence_id", name="checker_model_profiles_authority"), + CheckConstraint( + f"profile_digest ~ '{_SHA}' and registry_entry_digest ~ '{_SHA}' and " + f"pricing_policy_digest ~ '{_SHA}'", + name="digests", + ), + CheckConstraint( + "profile_version > 0 and pricing_policy_version > 0 and call_slots = 1", + name="versions", + ), + CheckConstraint( + "provider = 'openai_responses' and api = 'responses.v1'", + name="provider_api", + ), + CheckConstraint( + "maximum_request_bytes between 1 and 1048576 and " + "maximum_output_bytes between 1 and 1048576 and " + "maximum_response_bytes between 1 and 1048576 and " + "maximum_response_bytes >= 6 * maximum_output_bytes + 4096 and " + "maximum_input_tokens between 1 and 10000000 and " + "maximum_output_tokens between 1 and 10000000 and " + "deadline_ms between 100 and 3600000", + name="bounds", + ), + CheckConstraint( + "uncached_input_rate >= 0 and cached_input_rate >= 0 and " + "output_rate >= 0 and " + "greatest(uncached_input_rate,cached_input_rate,output_rate) > 0", + name="pricing", + ), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + project_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False + ) + profile_version: Mapped[int] = mapped_column(Integer, nullable=False) + profile_digest: Mapped[str] = mapped_column(String(71), nullable=False) + registry_entry_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("external_checker_registry_entries.id", ondelete="RESTRICT"), + nullable=False, + ) + registry_entry_digest: Mapped[str] = mapped_column(String(71), nullable=False) + provider: Mapped[str] = mapped_column(String(160), nullable=False) + api: Mapped[str] = mapped_column(String(160), nullable=False) + model_snapshot: Mapped[str] = mapped_column(String(160), nullable=False) + generation_settings: Mapped[dict] = mapped_column(JSONB(), nullable=False) + maximum_request_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_output_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_response_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_input_tokens: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_output_tokens: Mapped[int] = mapped_column(Integer, nullable=False) + deadline_ms: Mapped[int] = mapped_column(Integer, nullable=False) + call_slots: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + pricing_policy_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + pricing_policy_version: Mapped[int] = mapped_column(Integer, nullable=False) + pricing_policy_digest: Mapped[str] = mapped_column(String(71), nullable=False) + uncached_input_rate: Mapped[int] = mapped_column(BigInteger, nullable=False) + cached_input_rate: Mapped[int] = mapped_column(BigInteger, nullable=False) + output_rate: Mapped[int] = mapped_column(BigInteger, nullable=False) + operation_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + actor_profile_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("actor_profiles.id", ondelete="RESTRICT"), nullable=False + ) + authorization_evidence_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + + +class CheckerModelBudgetAccount(Base): + """One project lifetime account; configuration versions never reset counters.""" + + __tablename__ = "checker_model_budget_accounts" + __table_args__ = ( + ForeignKeyConstraint( + ["current_configuration_id", "project_id"], + [ + "checker_model_budget_configurations.id", + "checker_model_budget_configurations.project_id", + ], + ondelete="RESTRICT", + name="configuration_identity", + ), + CheckConstraint( + "lifetime_reserved_nanodollars >= 0 and lifetime_settled_nanodollars >= 0", + name="nonnegative_totals", + ), + ) + + project_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("projects.id", ondelete="RESTRICT"), primary_key=True + ) + current_configuration_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), nullable=False, unique=True + ) + lifetime_reserved_nanodollars: Mapped[int] = mapped_column( + BigInteger, nullable=False, default=0 + ) + lifetime_settled_nanodollars: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + + +class CheckerModelBudgetConfiguration(Base): + """Immutable Operator-authorized adjustment of one project's lifetime ceiling.""" + + __tablename__ = "checker_model_budget_configurations" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint( + "project_id", + "configuration_version", + name="checker_model_budget_configurations_version", + ), + UniqueConstraint( + "id", + "project_id", + "configuration_version", + "configuration_digest", + name="checker_model_budget_configurations_identity", + ), + UniqueConstraint( + "id", + "configuration_version", + "configuration_digest", + name="checker_model_budget_configurations_attempt_identity", + ), + UniqueConstraint("configuration_digest", name="checker_model_budget_configurations_digest"), + UniqueConstraint("operation_id", name="checker_model_budget_configurations_operation"), + UniqueConstraint( + "authorization_evidence_id", + name="checker_model_budget_configurations_authority", + ), + CheckConstraint(f"configuration_digest ~ '{_SHA}'", name="digest"), + CheckConstraint( + "configuration_version > 0 and lifetime_limit_nanodollars > 0", + name="positive_values", + ), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + project_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False + ) + configuration_version: Mapped[int] = mapped_column(Integer, nullable=False) + configuration_digest: Mapped[str] = mapped_column(String(71), nullable=False) + lifetime_limit_nanodollars: Mapped[int] = mapped_column(BigInteger, nullable=False) + operation_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + actor_profile_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("actor_profiles.id", ondelete="RESTRICT"), nullable=False + ) + authorization_evidence_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + + +class CheckerModelCallSlot(Base): + """Submission-stable paid effect identity independent of run generation and lease.""" + + __tablename__ = "checker_model_call_slots" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint( + "project_id", + "assignment_id", + "submission_id", + "submission_version", + "requirement_id", + "registry_entry_digest", + "configuration_sha256", + "profile_id", + "profile_version", + "profile_digest", + "slot_index", + name="checker_model_call_slots_stable_identity", + ), + ForeignKeyConstraint( + ["profile_id", "profile_version", "profile_digest"], + [ + "checker_model_profiles.id", + "checker_model_profiles.profile_version", + "checker_model_profiles.profile_digest", + ], + name="profile_identity", + ondelete="RESTRICT", + ), + CheckConstraint( + f"registry_entry_digest ~ '{_SHA}' and configuration_sha256 ~ '{_SHA}' and " + f"profile_digest ~ '{_SHA}' and first_request_sha256 ~ '{_SHA}'", + name="digests", + ), + CheckConstraint( + "submission_version > 0 and profile_version > 0 and slot_index = 0", + name="versions", + ), + CheckConstraint( + "requirement_id ~ '^[a-z][a-z0-9_.-]{0,99}$'", + name="requirement_id", + ), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + project_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False + ) + task_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("workstream_tasks.id", ondelete="RESTRICT"), + nullable=False, + ) + assignment_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("task_assignments.id", ondelete="RESTRICT"), nullable=False + ) + submission_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("submissions.id", ondelete="RESTRICT"), nullable=False + ) + submission_version: Mapped[int] = mapped_column(Integer, nullable=False) + requirement_id: Mapped[str] = mapped_column(String(100), nullable=False) + registry_entry_digest: Mapped[str] = mapped_column(String(71), nullable=False) + configuration_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + profile_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + profile_version: Mapped[int] = mapped_column(Integer, nullable=False) + profile_digest: Mapped[str] = mapped_column(String(71), nullable=False) + slot_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + first_request_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + + +class CheckerModelCallAttempt(Base): + """One server-numbered provider effect attempt beneath a stable paid slot.""" + + __tablename__ = "checker_model_call_attempts" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint("slot_id", "attempt_number", name="checker_model_call_attempts_number"), + UniqueConstraint( + "internal_call_request_id", name="checker_model_call_attempts_internal_request" + ), + UniqueConstraint( + "prepared_authorization_evidence_id", + name="checker_model_call_attempts_prepare_authority", + ), + ForeignKeyConstraint( + [ + "budget_configuration_id", + "budget_configuration_version", + "budget_configuration_digest", + ], + [ + "checker_model_budget_configurations.id", + "checker_model_budget_configurations.configuration_version", + "checker_model_budget_configurations.configuration_digest", + ], + ondelete="RESTRICT", + name="budget_identity", + ), + CheckConstraint( + "state in ('prepared','dispatching','uncertain','completed','known_failed','known_no_effect')", + name="state", + ), + CheckConstraint(f"request_sha256 ~ '{_SHA}'", name="request_digest"), + CheckConstraint( + "attempt_number > 0 and (reserved_nanodollars is null or reserved_nanodollars > 0) " + "and (settled_nanodollars is null or settled_nanodollars >= 0)", + name="amounts", + ), + CheckConstraint( + "input_tokens is null or (input_tokens >= 0 and cached_input_tokens between 0 and input_tokens " + "and output_tokens >= 0 and reasoning_tokens between 0 and output_tokens)", + name="usage", + ), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + slot_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("checker_model_call_slots.id", ondelete="RESTRICT"), + nullable=False, + ) + attempt_number: Mapped[int] = mapped_column(Integer, nullable=False) + internal_call_request_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + parent_attempt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("checker_model_call_attempts.id", ondelete="RESTRICT"), + ) + request_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + state: Mapped[str] = mapped_column(String(32), nullable=False) + prepared_authorization_evidence_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), nullable=False + ) + request_binding_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("artifact_bindings.id", ondelete="RESTRICT") + ) + request_content_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("artifact_contents.id", ondelete="RESTRICT") + ) + request_put_attempt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("artifact_put_attempts.id", ondelete="RESTRICT") + ) + request_verification_receipt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("artifact_verification_receipts.id", ondelete="RESTRICT"), + ) + request_content_sha256: Mapped[str | None] = mapped_column(String(71)) + request_byte_count: Mapped[int | None] = mapped_column(Integer) + budget_configuration_id: Mapped[str | None] = mapped_column(Uuid(as_uuid=False)) + budget_configuration_version: Mapped[int | None] = mapped_column(Integer) + budget_configuration_digest: Mapped[str | None] = mapped_column(String(71)) + reserved_nanodollars: Mapped[int | None] = mapped_column(BigInteger) + dispatch_authorization_evidence_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), unique=True + ) + provider_response_id: Mapped[str | None] = mapped_column(String(256)) + response_binding_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("artifact_bindings.id", ondelete="RESTRICT") + ) + response_content_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("artifact_contents.id", ondelete="RESTRICT") + ) + response_put_attempt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("artifact_put_attempts.id", ondelete="RESTRICT") + ) + response_verification_receipt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("artifact_verification_receipts.id", ondelete="RESTRICT"), + ) + response_content_sha256: Mapped[str | None] = mapped_column(String(71)) + response_byte_count: Mapped[int | None] = mapped_column(Integer) + input_tokens: Mapped[int | None] = mapped_column(Integer) + cached_input_tokens: Mapped[int | None] = mapped_column(Integer) + output_tokens: Mapped[int | None] = mapped_column(Integer) + reasoning_tokens: Mapped[int | None] = mapped_column(Integer) + settled_nanodollars: Mapped[int | None] = mapped_column(BigInteger) + settlement_authorization_evidence_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), unique=True + ) + settled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + no_effect_proof_id: Mapped[str | None] = mapped_column(Uuid(as_uuid=False)) + no_effect_proof_digest: Mapped[str | None] = mapped_column(String(71)) + no_effect_observed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + terminal_authorization_evidence_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), unique=True + ) + uncertainty_digest: Mapped[str | None] = mapped_column(String(71)) + observation_authorization_evidence_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), unique=True + ) + prepared_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + dispatched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + terminal_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class CheckerModelCallRecovery(Base): + """One fresh current-run recovery attachment to an existing paid effect.""" + + __tablename__ = "checker_model_call_recoveries" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint( + "attempt_id", + "checker_run_id", + "worker_lease_id", + "worker_lease_generation", + name="checker_model_call_recoveries_attachment", + ), + UniqueConstraint( + "authorization_evidence_id", name="checker_model_call_recoveries_authority" + ), + CheckConstraint("worker_lease_generation > 0", name="lease_generation"), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + attempt_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("checker_model_call_attempts.id", ondelete="RESTRICT"), + nullable=False, + ) + checker_run_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("checker_runs.id", ondelete="RESTRICT"), nullable=False + ) + worker_lease_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + worker_lease_generation: Mapped[int] = mapped_column(Integer, nullable=False) + authorization_evidence_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + + +__all__ = ( + "CheckerModelBudgetAccount", + "CheckerModelBudgetConfiguration", + "CheckerModelCallAttempt", + "CheckerModelCallRecovery", + "CheckerModelCallSlot", + "CheckerModelProfile", +) diff --git a/backend/app/modules/checkers/model_calls/profiles.py b/backend/app/modules/checkers/model_calls/profiles.py new file mode 100644 index 000000000..cf7accd81 --- /dev/null +++ b/backend/app/modules/checkers/model_calls/profiles.py @@ -0,0 +1,104 @@ +"""Authority-neutral exact reads of immutable project model profiles.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.checkers.api.model_calls import ( + ModelCallUnavailable, + ModelGenerationSettings, + ModelPricing, + ModelProfileSelector, + ProjectModelProfile, + ProjectModelProfileSpec, +) +from app.modules.checkers.model_calls.models import CheckerModelProfile + + +def stored_model_profile(row: CheckerModelProfile) -> ProjectModelProfile: + """Decode one immutable row through the strict public profile contract.""" + pricing = ModelPricing( + pricing_policy_id=UUID(row.pricing_policy_id), + pricing_policy_version=row.pricing_policy_version, + pricing_policy_digest=row.pricing_policy_digest, + uncached_input_nanodollars_per_token=row.uncached_input_rate, + cached_input_nanodollars_per_token=row.cached_input_rate, + output_nanodollars_per_token=row.output_rate, + ) + spec = ProjectModelProfileSpec( + project_id=UUID(row.project_id), + profile_version=row.profile_version, + registry_entry_id=UUID(row.registry_entry_id), + registry_entry_digest=row.registry_entry_digest, + provider=row.provider, + api=row.api, + model_snapshot=row.model_snapshot, + settings=ModelGenerationSettings.model_validate(row.generation_settings), + maximum_request_bytes=row.maximum_request_bytes, + maximum_output_bytes=row.maximum_output_bytes, + maximum_response_bytes=row.maximum_response_bytes, + maximum_input_tokens=row.maximum_input_tokens, + maximum_output_tokens=row.maximum_output_tokens, + deadline_ms=row.deadline_ms, + call_slots=row.call_slots, + pricing=pricing, + ) + return ProjectModelProfile( + profile_id=UUID(row.id), + operation_id=UUID(row.operation_id), + actor_profile_id=UUID(row.actor_profile_id), + spec=spec, + profile_digest=row.profile_digest, + authorization_evidence_id=UUID(row.authorization_evidence_id), + created_at=row.created_at, + ) + + +class ModelProfileReader: + """Read exact immutable metadata without profile-mutation authority.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + def _require_root_transaction(self) -> None: + transaction = self._session.sync_session.get_transaction() + if ( + transaction is None + or not transaction.is_active + or self._session.in_nested_transaction() + ): + raise ModelCallUnavailable("model_profile_unavailable") + + async def read_exact( + self, + selector: ModelProfileSelector, + *, + project_id: UUID, + registry_entry_id: UUID, + registry_entry_digest: str, + ) -> ProjectModelProfile: + """Require the complete selector plus exact project and registry custody.""" + self._require_root_transaction() + try: + selector = ModelProfileSelector.model_validate(selector) + except ValueError as exc: + raise ModelCallUnavailable("model_profile_unavailable") from exc + row = await self._session.scalar( + select(CheckerModelProfile).where( + CheckerModelProfile.id == str(selector.profile_id), + CheckerModelProfile.profile_version == selector.profile_version, + CheckerModelProfile.profile_digest == selector.profile_digest, + CheckerModelProfile.project_id == str(project_id), + CheckerModelProfile.registry_entry_id == str(registry_entry_id), + CheckerModelProfile.registry_entry_digest == registry_entry_digest, + ) + ) + if row is None: + raise ModelCallUnavailable("model_profile_unavailable") + return stored_model_profile(row) + + +__all__ = ("ModelProfileReader", "stored_model_profile") diff --git a/backend/app/modules/checkers/model_calls/proxy.py b/backend/app/modules/checkers/model_calls/proxy.py new file mode 100644 index 000000000..b73669fb3 --- /dev/null +++ b/backend/app/modules/checkers/model_calls/proxy.py @@ -0,0 +1,322 @@ +"""Durable model-call orchestration for project-owned checker images.""" + +import asyncio +from contextlib import AbstractAsyncContextManager +from collections.abc import Callable +from dataclasses import asdict +import hashlib +import json +from uuid import uuid4 + +from sqlalchemy import select, func + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.interfaces.checker_model_provider import ( + CheckerModelCreateOutcome, + CheckerModelProvider, + CheckerModelProviderStatus, + CheckerModelRecoveryOutcome, + CheckerModelRequest, + CheckerModelResponseReference, +) +from app.interfaces.checker_model_proxy import ModelProxyRequest, ModelProxyReply +from app.modules.checkers.api.model_calls import ( + ModelCallEffectAuthorityFacts, + ModelCallEvidence, + ModelCallUsage, + ModelCallUnavailable, + ModelProfileSelector, + PreparedModelCallAttempt, + KnownNoEffectProof, + ProjectModelProfile, +) +from app.modules.checkers.api.output_custody import ( + CheckerArtifactOutputPort, + CheckerOutputArtifactRequest, + CheckerOutputBindingRequest, + CheckerOutputSelector, + CheckerOutputUnavailable, +) + + +def _canonical_bytes(value: object) -> bytes: + """Serialize exact bounded private evidence without logging its contents.""" + return json.dumps( + value, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + + +def _sha(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +def _unavailable(code: str) -> ModelProxyReply: + return ModelProxyReply("unavailable", None, None, code) + + +class CheckerModelProxy: + """Persist intent before provider I/O and recover the original effect across runs.""" + + def __init__( + self, + *, + sessions, + authorization, + grants, + calls, + profiles, + outputs: CheckerArtifactOutputPort, + bindings, + provider: Callable[ + [ProjectModelProfile], AbstractAsyncContextManager[CheckerModelProvider] + ], + ): + self._sessions, self._authorization = sessions, authorization + self._grants, self._calls, self._profiles = grants, calls, profiles + self._outputs, self._bindings, self._provider = outputs, bindings, provider + + async def handle(self, request: ModelProxyRequest) -> ModelProxyReply: + """Consume one invocation capability; failed infrastructure never becomes a verdict.""" + try: + return await self._handle(request) + except (ModelCallUnavailable, CheckerOutputUnavailable): + return _unavailable("model_call_unavailable") + + async def _handle(self, request: ModelProxyRequest) -> ModelProxyReply: + data = _canonical_bytes( + { + "schema_version": "workstream.checker_model_request.v1", + "prompt": request.prompt, + } + ) + request_sha = _sha(data) + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, uuid4()) + grants = self._grants(session, authority) + facts = await grants.resolve(request.grant_id, request.request_digest) + profile = await self._profiles(session).read_exact( + ModelProfileSelector( + profile_id=facts.slot.profile_id, + profile_version=facts.slot.profile_version, + profile_digest=facts.slot.profile_digest, + ), + project_id=facts.slot.project_id, + registry_entry_id=facts.registry_entry_id, + registry_entry_digest=facts.slot.registry_entry_digest, + ) + if len(data) > profile.spec.maximum_request_bytes: + return _unavailable("model_request_too_large") + attached = await self._calls(session, authority).prepare_attempt( + facts.execution, + facts.slot, + registry_entry_id=facts.registry_entry_id, + request_sha256=request_sha, + ) + await grants.consume_prepared( + facts, + request_sha256=request_sha, + prepared_attempt_id=attached.attempt_id, + ) + effect = ModelCallEffectAuthorityFacts( + project_id=facts.execution.project_id, + task_id=facts.execution.task_id, + slot_id=attached.slot_id, + slot_key=attached.slot_key, + attempt_id=attached.attempt_id, + internal_call_request_id=attached.internal_call_request_id, + ) + if attached.state == "completed": + return await self._cached(facts, effect) + if attached.state in {"known_failed", "known_no_effect"}: + return _unavailable("model_call_operator_recovery_required") + async with self._provider(profile) as provider: + dispatch = None + if attached.state == "prepared": + prepared = PreparedModelCallAttempt.model_validate(attached.model_dump()) + request_evidence = await self._store(facts, effect, "model_request", data) + async with self._sessions() as session, session.begin(): + authority = await self._authorization( + session, facts.execution.evaluation_request_id + ) + dispatch = await self._calls(session, authority).reserve_dispatch( + facts.execution, + prepared, + request_evidence, + ) + if dispatch.state == "completed": + return await self._cached(facts, effect) + deadline = asyncio.get_running_loop().time() + profile.spec.deadline_ms / 1000 + if dispatch is not None and not dispatch.replayed: + try: + created = await asyncio.wait_for( + provider.create(CheckerModelRequest(request.prompt)), + timeout=max(0.001, deadline - asyncio.get_running_loop().time()), + ) + except TimeoutError: + await self._record_reference(effect, None) + return _unavailable("model_provider_delivery_uncertain") + if created.outcome is CheckerModelCreateOutcome.NO_EFFECT: + async with self._sessions() as session, session.begin(): + authority = await self._authorization( + session, effect.internal_call_request_id + ) + proof = KnownNoEffectProof( + proof_id=new_record_id(), + proof_digest=canonical_json_hash( + { + "attempt_id": str(effect.attempt_id), + "provider_outcome": created.outcome.value, + "failure_code": created.failure.value, + } + ), + observed_at=await session.scalar(select(func.clock_timestamp())), + ) + await self._calls(session, authority).mark_known_no_effect(effect, proof) + return _unavailable("model_provider_no_effect") + reference = created.known_response + await self._record_reference(effect, reference) + if created.outcome is CheckerModelCreateOutcome.UNCERTAIN: + return _unavailable("model_provider_delivery_uncertain") + observation = created.observation + else: + async with self._sessions() as session, session.begin(): + authority = await self._authorization( + session, facts.execution.evaluation_request_id + ) + recovery = await self._calls(session, authority).attach_recovery( + facts.execution, effect + ) + reference = CheckerModelResponseReference(recovery.provider_response_id) + observation = None + while True: + if observation is not None and observation.status not in { + CheckerModelProviderStatus.QUEUED, + CheckerModelProviderStatus.IN_PROGRESS, + }: + if observation.usage is None: + return _unavailable("model_provider_usage_unavailable") + return await self._complete(facts, effect, profile, observation) + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + return _unavailable("model_provider_pending") + await asyncio.sleep(min(1, remaining)) + try: + recovered = await asyncio.wait_for( + provider.recover(reference), + timeout=max(0.001, deadline - asyncio.get_running_loop().time()), + ) + except TimeoutError: + return _unavailable("model_provider_pending") + if recovered.outcome is not CheckerModelRecoveryOutcome.OBSERVED: + return _unavailable("model_provider_recovery_unavailable") + observation = recovered.observation + + async def _record_reference(self, effect, reference) -> None: + """Persist the provider identity before response storage or further polling.""" + provider_id = reference.provider_response_id if reference is not None else None + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, effect.internal_call_request_id) + await self._calls(session, authority).mark_uncertain( + effect, + canonical_json_hash({"provider_response_id": provider_id}), + provider_response_id=provider_id, + ) + + async def _store(self, facts, effect, role: str, data: bytes) -> ModelCallEvidence: + """Use ART's existing prepare, immutable put, verification and binding pipeline.""" + selector = CheckerOutputSelector( + paid_slot=effect.slot_key, + task_id=facts.execution.task_id, + model_call_slot_id=effect.slot_id, + model_call_attempt_id=effect.attempt_id, + slot_key=role, + content_sha256=_sha(data), + execution=facts.execution if role == "model_request" else None, + ) + + async def source(): + yield data + + stored = await self._outputs.store(CheckerOutputArtifactRequest(selector, source())) + if stored.status != "verified" or stored.verification_receipt_id is None: + raise ModelCallUnavailable("model_call_evidence_unavailable") + async with self._sessions() as session, session.begin(): + binding = await self._bindings(session).bind_checker_output( + CheckerOutputBindingRequest( + selector, + stored.put_attempt_id, + stored.verification_receipt_id, + ) + ) + return ModelCallEvidence( + binding_id=binding.binding_id, + content_id=binding.content_id, + put_attempt_id=binding.put_attempt_id, + verification_receipt_id=binding.verification_receipt_id, + content_sha256=selector.content_sha256, + byte_count=len(data), + ) + + async def _complete(self, facts, effect, profile, observation) -> ModelProxyReply: + """Record authenticated terminal evidence before settling once, including late results.""" + data = _canonical_bytes( + { + "schema_version": "workstream.checker_model_response.v1", + "observation": asdict(observation), + } + ) + if len(data) > profile.spec.maximum_response_bytes: + return _unavailable("model_response_too_large") + evidence = await self._store(facts, effect, "model_response", data) + raw_usage = observation.usage + usage = ModelCallUsage( + input_tokens=raw_usage.input_tokens, + cached_input_tokens=raw_usage.cached_input_tokens, + output_tokens=raw_usage.output_tokens, + reasoning_tokens=raw_usage.reasoning_tokens, + ) + usable = observation.status is CheckerModelProviderStatus.COMPLETED and bool( + observation.output_text + ) + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, effect.internal_call_request_id) + calls = self._calls(session, authority) + operation = calls.complete if usable else calls.complete_known_failure + await operation( + effect, + provider_response_id=observation.reference.provider_response_id, + provider_model_snapshot=observation.provider_model, + response_evidence=evidence, + usage=usage, + ) + if not usable: + return _unavailable("model_provider_failed") + # Fresh current-run attachment gates image access; late completion itself remains retained. + return await self._cached(facts, effect) + + async def _cached(self, facts, effect) -> ModelProxyReply: + """Reauthorize current invocation before releasing the original verified response.""" + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, facts.execution.evaluation_request_id) + completed = await self._calls(session, authority).attach_completed( + facts.execution, effect + ) + selector = CheckerOutputSelector( + paid_slot=effect.slot_key, + task_id=facts.execution.task_id, + model_call_slot_id=effect.slot_id, + model_call_attempt_id=effect.attempt_id, + slot_key="model_response", + content_sha256=completed.response_evidence.content_sha256, + ) + data = await self._outputs.read_response(selector, completed.response_evidence) + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, facts.execution.evaluation_request_id) + await self._calls(session, authority).attach_completed(facts.execution, effect) + body = json.loads(data) + if body.get("schema_version") != "workstream.checker_model_response.v1": + raise ModelCallUnavailable("model_call_response_unavailable") + output = body["observation"]["output_text"] + receipt = canonical_json_hash(completed.model_dump(mode="json", exclude={"replayed"})) + return ModelProxyReply("completed", output, receipt, None) diff --git a/backend/app/modules/checkers/model_calls/repository.py b/backend/app/modules/checkers/model_calls/repository.py new file mode 100644 index 000000000..6ef0a056a --- /dev/null +++ b/backend/app/modules/checkers/model_calls/repository.py @@ -0,0 +1,181 @@ +"""Flush-only persistence and lock order for project model-call ownership.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.checkers.api.model_calls import ModelPaidSlotKey, ModelCallUnavailable +from app.modules.checkers.model_calls.models import ( + CheckerModelBudgetAccount, + CheckerModelBudgetConfiguration, + CheckerModelCallAttempt, + CheckerModelCallRecovery, + CheckerModelCallSlot, + CheckerModelProfile, +) + + +def require_model_call_transaction(session: AsyncSession) -> None: + """Require one caller-owned root transaction and reject nested ownership.""" + transaction = session.sync_session.get_transaction() + if transaction is None or not transaction.is_active or session.in_nested_transaction(): + raise ModelCallUnavailable("model_call_transaction_unavailable") + + +class ModelCallRepository: + """Own model-call row locks without committing the caller's transaction.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def now(self): + return await self._session.scalar(select(func.clock_timestamp())) + + async def advisory(self, key: str) -> None: + await self._session.execute( + text("select pg_advisory_xact_lock(hashtextextended(:key, 0))"), + {"key": key}, + ) + + async def lock_profile_scopes( + self, operation_id: UUID, project_id: UUID, profile_version: int + ) -> None: + for key in ( + f"checker-model-profile-operation:{operation_id}", + f"checker-model-profile:{project_id}", + ): + await self.advisory(key) + + async def profile_by_operation(self, operation_id: UUID): + return await self._session.scalar( + select(CheckerModelProfile).where(CheckerModelProfile.operation_id == str(operation_id)) + ) + + async def profile_by_version(self, project_id: UUID, profile_version: int): + return await self._session.scalar( + select(CheckerModelProfile).where( + CheckerModelProfile.project_id == str(project_id), + CheckerModelProfile.profile_version == profile_version, + ) + ) + + async def latest_profile(self, project_id: UUID): + return await self._session.scalar( + select(CheckerModelProfile) + .where(CheckerModelProfile.project_id == str(project_id)) + .order_by(CheckerModelProfile.profile_version.desc()) + .limit(1) + ) + + async def profile_exact(self, profile_id: UUID, profile_version: int, profile_digest: str): + return await self._session.scalar( + select(CheckerModelProfile).where( + CheckerModelProfile.id == str(profile_id), + CheckerModelProfile.profile_version == profile_version, + CheckerModelProfile.profile_digest == profile_digest, + ) + ) + + async def lock_budget_scope(self, project_id: UUID) -> None: + await self.advisory(f"checker-model-budget:{project_id}") + + async def budget_account(self, project_id: UUID, *, lock: bool = False): + query = select(CheckerModelBudgetAccount).where( + CheckerModelBudgetAccount.project_id == str(project_id) + ) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def budget_configuration_by_operation(self, operation_id: UUID): + return await self._session.scalar( + select(CheckerModelBudgetConfiguration).where( + CheckerModelBudgetConfiguration.operation_id == str(operation_id) + ) + ) + + async def budget_configuration_by_version(self, project_id: UUID, version: int): + return await self._session.scalar( + select(CheckerModelBudgetConfiguration).where( + CheckerModelBudgetConfiguration.project_id == str(project_id), + CheckerModelBudgetConfiguration.configuration_version == version, + ) + ) + + async def current_budget_configuration(self, account: CheckerModelBudgetAccount): + return await self._session.get( + CheckerModelBudgetConfiguration, + account.current_configuration_id, + populate_existing=True, + ) + + @staticmethod + def _slot_predicates(key: ModelPaidSlotKey): + return ( + CheckerModelCallSlot.project_id == str(key.project_id), + CheckerModelCallSlot.assignment_id == str(key.assignment_id), + CheckerModelCallSlot.submission_id == str(key.submission_id), + CheckerModelCallSlot.submission_version == key.submission_version, + CheckerModelCallSlot.requirement_id == key.requirement_id, + CheckerModelCallSlot.registry_entry_digest == key.registry_entry_digest, + CheckerModelCallSlot.configuration_sha256 == key.configuration_sha256, + CheckerModelCallSlot.profile_id == str(key.profile_id), + CheckerModelCallSlot.profile_version == key.profile_version, + CheckerModelCallSlot.profile_digest == key.profile_digest, + CheckerModelCallSlot.slot_index == key.slot_index, + ) + + async def lock_slot_scope(self, key: ModelPaidSlotKey) -> None: + await self.advisory("checker-model-call-slot:" + key.digest) + + async def slot(self, key: ModelPaidSlotKey, *, lock: bool = False): + query = select(CheckerModelCallSlot).where(*self._slot_predicates(key)) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def latest_attempt(self, slot_id: str, *, lock: bool = False): + query = ( + select(CheckerModelCallAttempt) + .where(CheckerModelCallAttempt.slot_id == slot_id) + .order_by(CheckerModelCallAttempt.attempt_number.desc()) + .limit(1) + ) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def attempt(self, attempt_id: UUID, *, lock: bool = False): + query = select(CheckerModelCallAttempt).where(CheckerModelCallAttempt.id == str(attempt_id)) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def recovery( + self, + attempt_id: UUID, + checker_run_id: UUID, + worker_lease_id: UUID, + worker_lease_generation: int, + ): + return await self._session.scalar( + select(CheckerModelCallRecovery).where( + CheckerModelCallRecovery.attempt_id == str(attempt_id), + CheckerModelCallRecovery.checker_run_id == str(checker_run_id), + CheckerModelCallRecovery.worker_lease_id == str(worker_lease_id), + CheckerModelCallRecovery.worker_lease_generation == worker_lease_generation, + ) + ) + + async def add(self, row) -> None: + self._session.add(row) + await self._session.flush() + + async def flush(self) -> None: + await self._session.flush() + + +__all__ = ("ModelCallRepository", "require_model_call_transaction") diff --git a/backend/app/modules/checkers/model_calls/service.py b/backend/app/modules/checkers/model_calls/service.py new file mode 100644 index 000000000..30efc19b3 --- /dev/null +++ b/backend/app/modules/checkers/model_calls/service.py @@ -0,0 +1,1135 @@ +"""Transactional owner for durable, project-budgeted checker model calls.""" + +from __future__ import annotations + +from uuid import UUID + +from pydantic import TypeAdapter +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.checkers.api.model_calls import ( + AttachedModelCallAttempt, + CompletedModelCall, + KnownFailedModelCall, + KnownNoEffectProof, + ModelActionEvidence, + ModelBudgetProvisioningFacts, + ModelCallAuthorityPort, + ModelCallBudgetUnavailable, + ModelCallConflict, + ModelCallCurrentExecutionPort, + ModelCallEvidence, + ModelCallEffectAuthorityFacts, + ModelCallEvidencePort, + ModelCallExecutionFacts, + ModelCallRecoveryAttachment, + ModelCallUnavailable, + ModelCallUsage, + ModelDispatchReservation, + ModelPaidSlotKey, + ModelProfileProvisioningFacts, + ModelProfileSelector, + PreparedModelCallAttempt, + ProviderResponseIdentifier, + ProjectModelBudgetConfiguration, + ProjectModelProfile, + model_action_digest, + model_retry_terminal_digest, + model_usage_cost, +) +from app.modules.checkers.model_calls.models import ( + CheckerModelBudgetAccount, + CheckerModelBudgetConfiguration, + CheckerModelCallAttempt, + CheckerModelCallRecovery, + CheckerModelCallSlot, + CheckerModelProfile, +) +from app.modules.checkers.model_calls.repository import ( + ModelCallRepository, + require_model_call_transaction, +) +from app.modules.checkers.model_calls.profiles import stored_model_profile + + +def _uuid(value: str) -> UUID: + return UUID(value) + + +def _same_uuid(value: str, expected: UUID) -> bool: + return value == str(expected) + + +_SHA_ADAPTER = TypeAdapter(str) +_PROVIDER_RESPONSE_ADAPTER = TypeAdapter(ProviderResponseIdentifier) + + +def _sha(value: str) -> str: + # Pydantic constrained aliases are validated by their containing public values; + # raw service arguments receive the equivalent closed check here. + if len(value) != 71 or not value.startswith("sha256:"): + raise ModelCallConflict("model_call_digest_invalid") + try: + int(value[7:], 16) + except ValueError as exc: + raise ModelCallConflict("model_call_digest_invalid") from exc + if value[7:] != value[7:].lower(): + raise ModelCallConflict("model_call_digest_invalid") + return _SHA_ADAPTER.validate_python(value, strict=True) + + +def _require_authority(action: str, facts: object, evidence: ModelActionEvidence) -> None: + if evidence.facts_digest != model_action_digest(action, facts): + raise ModelCallUnavailable("model_call_authority_unavailable") + + +def _slot_key(row: CheckerModelCallSlot) -> ModelPaidSlotKey: + return ModelPaidSlotKey( + project_id=_uuid(row.project_id), + assignment_id=_uuid(row.assignment_id), + submission_id=_uuid(row.submission_id), + submission_version=row.submission_version, + requirement_id=row.requirement_id, + registry_entry_digest=row.registry_entry_digest, + configuration_sha256=row.configuration_sha256, + profile_id=_uuid(row.profile_id), + profile_version=row.profile_version, + profile_digest=row.profile_digest, + slot_index=row.slot_index, + ) + + +def _evidence(row: CheckerModelCallAttempt, prefix: str) -> ModelCallEvidence: + values = { + "binding_id": getattr(row, f"{prefix}_binding_id"), + "content_id": getattr(row, f"{prefix}_content_id"), + "put_attempt_id": getattr(row, f"{prefix}_put_attempt_id"), + "verification_receipt_id": getattr(row, f"{prefix}_verification_receipt_id"), + "content_sha256": getattr(row, f"{prefix}_content_sha256"), + "byte_count": getattr(row, f"{prefix}_byte_count"), + } + if any(value is None for value in values.values()): + raise ModelCallUnavailable("model_call_evidence_unavailable") + for field in ( + "binding_id", + "content_id", + "put_attempt_id", + "verification_receipt_id", + ): + values[field] = _uuid(values[field]) + return ModelCallEvidence.model_validate(values) + + +def _attempt( + slot: CheckerModelCallSlot, + row: CheckerModelCallAttempt, + *, + replayed: bool, +) -> AttachedModelCallAttempt: + values = dict( + slot_id=_uuid(slot.id), + slot_key=_slot_key(slot), + attempt_id=_uuid(row.id), + attempt_number=row.attempt_number, + internal_call_request_id=_uuid(row.internal_call_request_id), + request_sha256=row.request_sha256, + state=row.state, + replayed=replayed, + ) + if row.state == "prepared": + return PreparedModelCallAttempt.model_validate(values) + return AttachedModelCallAttempt.model_validate(values) + + +def _effect( + slot: CheckerModelCallSlot, row: CheckerModelCallAttempt +) -> ModelCallEffectAuthorityFacts: + return ModelCallEffectAuthorityFacts( + project_id=_uuid(slot.project_id), + task_id=_uuid(slot.task_id), + slot_id=_uuid(slot.id), + slot_key=_slot_key(slot), + attempt_id=_uuid(row.id), + internal_call_request_id=_uuid(row.internal_call_request_id), + ) + + +def _require_effect( + supplied: ModelCallEffectAuthorityFacts, + slot: CheckerModelCallSlot, + row: CheckerModelCallAttempt, +) -> None: + if supplied != _effect(slot, row): + raise ModelCallUnavailable("model_call_effect_unavailable") + + +def _usage(row: CheckerModelCallAttempt) -> ModelCallUsage | None: + values = ( + row.input_tokens, + row.cached_input_tokens, + row.output_tokens, + row.reasoning_tokens, + ) + if all(value is None for value in values): + return None + if any(value is None for value in values): + raise ModelCallUnavailable("model_call_usage_unavailable") + return ModelCallUsage( + input_tokens=row.input_tokens, + cached_input_tokens=row.cached_input_tokens, + output_tokens=row.output_tokens, + reasoning_tokens=row.reasoning_tokens, + ) + + +class ModelCallService: + """Flush-only state machine; the caller owns commit or rollback.""" + + def __init__( + self, + session: AsyncSession, + *, + authority: ModelCallAuthorityPort, + current_execution: ModelCallCurrentExecutionPort, + evidence: ModelCallEvidencePort, + ) -> None: + self._session = session + self._repo = ModelCallRepository(session) + self._authority = authority + self._current = current_execution + self._evidence = evidence + + def _require_transaction(self) -> None: + require_model_call_transaction(self._session) + + async def provision_profile(self, facts: ModelProfileProvisioningFacts) -> ProjectModelProfile: + """Publish one immutable Operator-authorized exact model profile.""" + self._require_transaction() + facts = ModelProfileProvisioningFacts.model_validate(facts) + _ = facts.spec.maximum_reservation_nanodollars + authority = await self._authority.authorize_profile(facts) + _require_authority("profile", facts, authority) + + await self._repo.lock_profile_scopes( + facts.operation_id, facts.spec.project_id, facts.spec.profile_version + ) + by_operation = await self._repo.profile_by_operation(facts.operation_id) + if by_operation is not None: + existing = stored_model_profile(by_operation) + stored_facts = ModelProfileProvisioningFacts( + operation_id=existing.operation_id, + actor_profile_id=existing.actor_profile_id, + spec=existing.spec, + profile_digest=existing.profile_digest, + ) + if stored_facts != facts: + raise ModelCallConflict("model_profile_operation_conflict") + return existing + if ( + await self._repo.profile_by_version(facts.spec.project_id, facts.spec.profile_version) + is not None + ): + raise ModelCallConflict("model_profile_version_conflict") + latest = await self._repo.latest_profile(facts.spec.project_id) + expected_version = 1 if latest is None else latest.profile_version + 1 + if facts.spec.profile_version != expected_version: + raise ModelCallConflict("model_profile_version_conflict") + + spec = facts.spec + pricing = spec.pricing + row = CheckerModelProfile( + id=str(new_record_id()), + project_id=str(spec.project_id), + profile_version=spec.profile_version, + profile_digest=facts.profile_digest, + registry_entry_id=str(spec.registry_entry_id), + registry_entry_digest=spec.registry_entry_digest, + provider=spec.provider, + api=spec.api, + model_snapshot=spec.model_snapshot, + generation_settings=spec.settings.model_dump(mode="json"), + maximum_request_bytes=spec.maximum_request_bytes, + maximum_output_bytes=spec.maximum_output_bytes, + maximum_response_bytes=spec.maximum_response_bytes, + maximum_input_tokens=spec.maximum_input_tokens, + maximum_output_tokens=spec.maximum_output_tokens, + deadline_ms=spec.deadline_ms, + call_slots=spec.call_slots, + pricing_policy_id=str(pricing.pricing_policy_id), + pricing_policy_version=pricing.pricing_policy_version, + pricing_policy_digest=pricing.pricing_policy_digest, + uncached_input_rate=pricing.uncached_input_nanodollars_per_token, + cached_input_rate=pricing.cached_input_nanodollars_per_token, + output_rate=pricing.output_nanodollars_per_token, + operation_id=str(facts.operation_id), + actor_profile_id=str(facts.actor_profile_id), + authorization_evidence_id=str(authority.evidence_id), + ) + await self._repo.add(row) + return stored_model_profile(row) + + async def provision_budget( + self, facts: ModelBudgetProvisioningFacts + ) -> ProjectModelBudgetConfiguration: + """Advance a ceiling version without resetting project lifetime counters.""" + self._require_transaction() + facts = ModelBudgetProvisioningFacts.model_validate(facts) + authority = await self._authority.authorize_budget(facts) + _require_authority("budget", facts, authority) + spec = facts.spec + + await self._repo.lock_budget_scope(spec.project_id) + by_operation = await self._repo.budget_configuration_by_operation(facts.operation_id) + if by_operation is not None: + account = await self._repo.budget_account(spec.project_id, lock=True) + if account is None: + raise ModelCallUnavailable("model_budget_account_unavailable") + result = self._budget_result(by_operation, account) + stored_facts = ModelBudgetProvisioningFacts( + operation_id=result.operation_id, + actor_profile_id=result.actor_profile_id, + spec=result.spec, + configuration_digest=result.configuration_digest, + ) + if stored_facts != facts: + raise ModelCallConflict("model_budget_operation_conflict") + return result + + account = await self._repo.budget_account(spec.project_id, lock=True) + current = ( + await self._repo.current_budget_configuration(account) if account is not None else None + ) + expected_version = 1 if current is None else current.configuration_version + 1 + if spec.configuration_version != expected_version: + raise ModelCallConflict("model_budget_version_conflict") + if ( + await self._repo.budget_configuration_by_version( + spec.project_id, spec.configuration_version + ) + is not None + ): + raise ModelCallConflict("model_budget_version_conflict") + reserved = 0 if account is None else account.lifetime_reserved_nanodollars + settled = 0 if account is None else account.lifetime_settled_nanodollars + if spec.lifetime_limit_nanodollars < reserved + settled: + raise ModelCallBudgetUnavailable("model_budget_below_lifetime_usage") + + row = CheckerModelBudgetConfiguration( + id=str(new_record_id()), + project_id=str(spec.project_id), + configuration_version=spec.configuration_version, + configuration_digest=facts.configuration_digest, + lifetime_limit_nanodollars=spec.lifetime_limit_nanodollars, + operation_id=str(facts.operation_id), + actor_profile_id=str(facts.actor_profile_id), + authorization_evidence_id=str(authority.evidence_id), + ) + await self._repo.add(row) + if account is None: + account = CheckerModelBudgetAccount( + project_id=str(spec.project_id), + current_configuration_id=row.id, + lifetime_reserved_nanodollars=0, + lifetime_settled_nanodollars=0, + ) + await self._repo.add(account) + else: + account.current_configuration_id = row.id + await self._repo.flush() + return self._budget_result(row, account) + + @staticmethod + def _budget_result( + row: CheckerModelBudgetConfiguration, account: CheckerModelBudgetAccount + ) -> ProjectModelBudgetConfiguration: + return ProjectModelBudgetConfiguration( + configuration_id=_uuid(row.id), + operation_id=_uuid(row.operation_id), + actor_profile_id=_uuid(row.actor_profile_id), + spec={ + "project_id": _uuid(row.project_id), + "configuration_version": row.configuration_version, + "lifetime_limit_nanodollars": row.lifetime_limit_nanodollars, + }, + configuration_digest=row.configuration_digest, + authorization_evidence_id=_uuid(row.authorization_evidence_id), + created_at=row.created_at, + lifetime_reserved_nanodollars=account.lifetime_reserved_nanodollars, + lifetime_settled_nanodollars=account.lifetime_settled_nanodollars, + ) + + async def read_exact( + self, + selector: ModelProfileSelector, + *, + project_id: UUID, + registry_entry_id: UUID, + registry_entry_digest: str, + ) -> ProjectModelProfile: + """Read an exact profile without treating a partial selector as authority.""" + self._require_transaction() + selector = ModelProfileSelector.model_validate(selector) + row = await self._repo.profile_exact( + selector.profile_id, selector.profile_version, selector.profile_digest + ) + if row is None: + raise ModelCallUnavailable("model_profile_unavailable") + result = stored_model_profile(row) + if ( + result.spec.project_id != project_id + or result.spec.registry_entry_id != registry_entry_id + or result.spec.registry_entry_digest != registry_entry_digest + ): + raise ModelCallUnavailable("model_profile_unavailable") + return result + + @staticmethod + def _require_execution_slot(execution: ModelCallExecutionFacts, slot: ModelPaidSlotKey) -> None: + if ( + execution.project_id != slot.project_id + or execution.assignment_id != slot.assignment_id + or execution.submission_id != slot.submission_id + or execution.submission_version != slot.submission_version + ): + raise ModelCallUnavailable("model_call_execution_unavailable") + + async def _profile_for_slot( + self, slot: ModelPaidSlotKey, registry_entry_id: UUID + ) -> ProjectModelProfile: + return await self.read_exact( + ModelProfileSelector( + profile_id=slot.profile_id, + profile_version=slot.profile_version, + profile_digest=slot.profile_digest, + ), + project_id=slot.project_id, + registry_entry_id=registry_entry_id, + registry_entry_digest=slot.registry_entry_digest, + ) + + async def _profile_for_stored_slot(self, slot: ModelPaidSlotKey) -> ProjectModelProfile: + row = await self._repo.profile_exact( + slot.profile_id, slot.profile_version, slot.profile_digest + ) + if row is None: + raise ModelCallUnavailable("model_profile_unavailable") + result = stored_model_profile(row) + if ( + result.spec.project_id != slot.project_id + or result.spec.registry_entry_digest != slot.registry_entry_digest + ): + raise ModelCallUnavailable("model_profile_unavailable") + return result + + async def prepare_attempt( + self, + execution: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + *, + registry_entry_id: UUID, + request_sha256: str, + ) -> AttachedModelCallAttempt: + """Allocate a server-owned attempt before private request ART upload.""" + self._require_transaction() + execution = ModelCallExecutionFacts.model_validate(execution) + slot = ModelPaidSlotKey.model_validate(slot) + request_sha256 = _sha(request_sha256) + self._require_execution_slot(execution, slot) + authority = await self._authority.authorize_prepare(execution, slot, request_sha256) + _require_authority( + "prepare", + { + "execution": execution.model_dump(mode="json"), + "slot": slot.model_dump(mode="json"), + "request_sha256": request_sha256, + }, + authority, + ) + await self._current.require_current(execution, slot) + await self._profile_for_slot(slot, registry_entry_id) + + await self._repo.lock_slot_scope(slot) + slot_row = await self._repo.slot(slot, lock=True) + if slot_row is None: + slot_row = CheckerModelCallSlot( + id=str(new_record_id()), + task_id=str(execution.task_id), + **slot.model_dump(mode="json"), + first_request_sha256=request_sha256, + ) + await self._repo.add(slot_row) + elif slot_row.first_request_sha256 != request_sha256 or slot_row.task_id != str( + execution.task_id + ): + raise ModelCallConflict("model_call_request_conflict") + attempt = await self._repo.latest_attempt(slot_row.id, lock=True) + if attempt is not None: + return _attempt(slot_row, attempt, replayed=True) + + attempt = CheckerModelCallAttempt( + id=str(new_record_id()), + slot_id=slot_row.id, + attempt_number=1, + internal_call_request_id=str(new_record_id()), + request_sha256=request_sha256, + state="prepared", + prepared_authorization_evidence_id=str(authority.evidence_id), + ) + await self._repo.add(attempt) + return _attempt(slot_row, attempt, replayed=False) + + async def prepare_recovery_child( + self, + execution: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + *, + registry_entry_id: UUID, + parent_attempt_id: UUID, + terminal_digest: str, + request_sha256: str, + ) -> PreparedModelCallAttempt: + """Allocate N+1 only after an authorized, fully-known terminal parent.""" + self._require_transaction() + execution = ModelCallExecutionFacts.model_validate(execution) + slot = ModelPaidSlotKey.model_validate(slot) + terminal_digest = _sha(terminal_digest) + request_sha256 = _sha(request_sha256) + self._require_execution_slot(execution, slot) + authority = await self._authority.authorize_child( + execution, parent_attempt_id, terminal_digest + ) + child_facts = { + "execution": execution.model_dump(mode="json"), + "parent_attempt_id": str(parent_attempt_id), + "terminal_digest": terminal_digest, + } + _require_authority("child", child_facts, authority) + await self._current.require_current(execution, slot) + await self._profile_for_slot(slot, registry_entry_id) + + await self._repo.lock_slot_scope(slot) + slot_row = await self._repo.slot(slot, lock=True) + if slot_row is None: + raise ModelCallUnavailable("model_call_parent_unavailable") + if slot_row.first_request_sha256 != request_sha256: + raise ModelCallConflict("model_call_request_conflict") + latest = await self._repo.latest_attempt(slot_row.id, lock=True) + if latest is None: + raise ModelCallUnavailable("model_call_parent_unavailable") + if latest.id != str(parent_attempt_id): + if ( + latest.attempt_number > 1 + and latest.state == "prepared" + and latest.parent_attempt_id == str(parent_attempt_id) + and latest.request_sha256 == request_sha256 + ): + return PreparedModelCallAttempt.model_validate( + _attempt(slot_row, latest, replayed=True) + ) + raise ModelCallConflict("model_call_parent_superseded") + if not self._eligible_parent(latest, terminal_digest): + raise ModelCallConflict("model_call_parent_not_retryable") + + child = CheckerModelCallAttempt( + id=str(new_record_id()), + slot_id=slot_row.id, + attempt_number=latest.attempt_number + 1, + internal_call_request_id=str(new_record_id()), + parent_attempt_id=str(parent_attempt_id), + request_sha256=request_sha256, + state="prepared", + prepared_authorization_evidence_id=str(authority.evidence_id), + ) + await self._repo.add(child) + return PreparedModelCallAttempt.model_validate(_attempt(slot_row, child, replayed=False)) + + @staticmethod + def _eligible_parent(row: CheckerModelCallAttempt, terminal_digest: str) -> bool: + if row.state == "known_no_effect": + actual = canonical_json_hash( + { + "state": row.state, + "proof_id": row.no_effect_proof_id, + "proof_digest": row.no_effect_proof_digest, + "observed_at": ( + row.no_effect_observed_at.isoformat() + if row.no_effect_observed_at is not None + else None + ), + } + ) + return row.reserved_nanodollars is not None and actual == terminal_digest + if row.state == "known_failed": + result = ModelCallService._failed_result(row, replayed=False) + return ( + row.settlement_authorization_evidence_id is not None + and row.settled_at is not None + and model_retry_terminal_digest(result) == terminal_digest + ) + return False + + async def reserve_dispatch( + self, + execution: ModelCallExecutionFacts, + attempt: PreparedModelCallAttempt, + request_evidence: ModelCallEvidence, + ) -> ModelDispatchReservation: + """Verify ART, then atomically persist intent and reserve lifetime spend.""" + self._require_transaction() + execution = ModelCallExecutionFacts.model_validate(execution) + attempt = PreparedModelCallAttempt.model_validate(attempt) + request_evidence = ModelCallEvidence.model_validate(request_evidence) + self._require_execution_slot(execution, attempt.slot_key) + authority = await self._authority.authorize_dispatch(execution, attempt, request_evidence) + dispatch_facts = { + "execution": execution.model_dump(mode="json"), + "attempt": attempt.model_dump(mode="json"), + "request_evidence": request_evidence.model_dump(mode="json"), + } + _require_authority("dispatch", dispatch_facts, authority) + await self._current.require_current(execution, attempt.slot_key) + await self._evidence.require_request(attempt, request_evidence) + profile = await self._profile_for_stored_slot(attempt.slot_key) + if ( + request_evidence.content_sha256 != attempt.request_sha256 + or request_evidence.byte_count > profile.spec.maximum_request_bytes + ): + raise ModelCallConflict("model_call_request_evidence_conflict") + + await self._repo.lock_slot_scope(attempt.slot_key) + slot_row = await self._repo.slot(attempt.slot_key, lock=True) + if slot_row is None or slot_row.id != str(attempt.slot_id): + raise ModelCallUnavailable("model_call_attempt_unavailable") + row = await self._repo.attempt(attempt.attempt_id, lock=True) + if row is None or row.slot_id != slot_row.id: + raise ModelCallUnavailable("model_call_attempt_unavailable") + if row.state != "prepared": + if _evidence(row, "request") != request_evidence: + raise ModelCallConflict("model_call_dispatch_conflict") + return self._dispatch_result(row, slot=slot_row, replayed=True) + if ( + row.request_sha256 != attempt.request_sha256 + or row.attempt_number != attempt.attempt_number + or row.internal_call_request_id != str(attempt.internal_call_request_id) + ): + raise ModelCallConflict("model_call_attempt_conflict") + + await self._repo.lock_budget_scope(attempt.slot_key.project_id) + account = await self._repo.budget_account(attempt.slot_key.project_id, lock=True) + if account is None: + raise ModelCallBudgetUnavailable("model_budget_unavailable") + budget = await self._repo.current_budget_configuration(account) + if budget is None: + raise ModelCallBudgetUnavailable("model_budget_unavailable") + reservation = profile.spec.maximum_reservation_nanodollars + if ( + account.lifetime_reserved_nanodollars + + account.lifetime_settled_nanodollars + + reservation + > budget.lifetime_limit_nanodollars + ): + raise ModelCallBudgetUnavailable("model_budget_exhausted") + + account.lifetime_reserved_nanodollars += reservation + self._set_evidence(row, "request", request_evidence) + row.budget_configuration_id = budget.id + row.budget_configuration_version = budget.configuration_version + row.budget_configuration_digest = budget.configuration_digest + row.reserved_nanodollars = reservation + row.dispatch_authorization_evidence_id = str(authority.evidence_id) + row.dispatched_at = await self._repo.now() + row.state = "dispatching" + await self._repo.flush() + return self._dispatch_result(row, slot=slot_row, replayed=False) + + @staticmethod + def _set_evidence( + row: CheckerModelCallAttempt, prefix: str, evidence: ModelCallEvidence + ) -> None: + for field, value in evidence.model_dump(mode="json", exclude={"media_type"}).items(): + setattr(row, f"{prefix}_{field}", value) + + @staticmethod + def _dispatch_result( + row: CheckerModelCallAttempt, + *, + slot: CheckerModelCallSlot | None = None, + replayed: bool, + ) -> ModelDispatchReservation: + if row.state not in {"dispatching", "uncertain", "completed"}: + raise ModelCallConflict("model_call_not_dispatchable") + if slot is None: + raise ModelCallUnavailable("model_call_effect_unavailable") + return ModelDispatchReservation( + slot_id=_uuid(row.slot_id), + attempt_id=_uuid(row.id), + attempt_number=row.attempt_number, + internal_call_request_id=_uuid(row.internal_call_request_id), + effect=_effect(slot, row), + request_sha256=row.request_sha256, + request_evidence=_evidence(row, "request"), + budget_configuration_id=_uuid(row.budget_configuration_id), + budget_configuration_version=row.budget_configuration_version, + budget_configuration_digest=row.budget_configuration_digest, + reserved_nanodollars=row.reserved_nanodollars, + state=row.state, + provider_response_id=row.provider_response_id, + response_evidence=( + _evidence(row, "response") if row.response_content_sha256 is not None else None + ), + replayed=replayed, + ) + + async def attach_recovery( + self, + execution: ModelCallExecutionFacts, + effect: ModelCallEffectAuthorityFacts, + ) -> ModelCallRecoveryAttachment: + """Attach a fresh run to an existing effect; this never creates a call.""" + self._require_transaction() + execution = ModelCallExecutionFacts.model_validate(execution) + effect = ModelCallEffectAuthorityFacts.model_validate(effect) + self._require_execution_slot(execution, effect.slot_key) + if execution.task_id != effect.task_id: + raise ModelCallUnavailable("model_call_execution_unavailable") + authority = await self._authority.authorize_recover(execution, effect) + facts = { + "execution": execution.model_dump(mode="json"), + "effect": effect.model_dump(mode="json"), + } + _require_authority("recover", facts, authority) + await self._current.require_current(execution, effect.slot_key) + row = await self._repo.attempt(effect.attempt_id) + if row is None: + raise ModelCallUnavailable("model_call_attempt_unavailable") + slot = await self._session.get(CheckerModelCallSlot, row.slot_id) + if slot is None: + raise ModelCallUnavailable("model_call_attempt_unavailable") + self._require_execution_slot(execution, _slot_key(slot)) + await self._repo.lock_slot_scope(_slot_key(slot)) + row = await self._repo.attempt(effect.attempt_id, lock=True) + if row is not None: + _require_effect(effect, slot, row) + recoverable = ( + row is not None + and row.provider_response_id is not None + and row.state in {"dispatching", "uncertain"} + ) + if not recoverable: + raise ModelCallUnavailable("model_call_recovery_unavailable") + existing = await self._repo.recovery( + effect.attempt_id, + execution.checker_run_id, + execution.worker_lease_id, + execution.worker_lease_generation, + ) + if existing is None: + await self._repo.add( + CheckerModelCallRecovery( + id=str(new_record_id()), + attempt_id=str(effect.attempt_id), + checker_run_id=str(execution.checker_run_id), + worker_lease_id=str(execution.worker_lease_id), + worker_lease_generation=execution.worker_lease_generation, + authorization_evidence_id=str(authority.evidence_id), + ) + ) + return ModelCallRecoveryAttachment( + slot_id=_uuid(slot.id), + attempt_id=_uuid(row.id), + attempt_number=row.attempt_number, + internal_call_request_id=_uuid(row.internal_call_request_id), + provider_response_id=row.provider_response_id, + state=row.state, + replayed=existing is not None, + ) + + async def attach_completed( + self, + execution: ModelCallExecutionFacts, + effect: ModelCallEffectAuthorityFacts, + ) -> CompletedModelCall: + """Attach a fresh current run to immutable cached response evidence.""" + self._require_transaction() + execution = ModelCallExecutionFacts.model_validate(execution) + effect = ModelCallEffectAuthorityFacts.model_validate(effect) + self._require_execution_slot(execution, effect.slot_key) + if execution.task_id != effect.task_id: + raise ModelCallUnavailable("model_call_execution_unavailable") + authority = await self._authority.authorize_recover(execution, effect) + facts = { + "execution": execution.model_dump(mode="json"), + "effect": effect.model_dump(mode="json"), + } + _require_authority("recover", facts, authority) + await self._current.require_current(execution, effect.slot_key) + row = await self._repo.attempt(effect.attempt_id) + if row is None: + raise ModelCallUnavailable("model_call_attempt_unavailable") + slot = await self._session.get(CheckerModelCallSlot, row.slot_id) + if slot is None: + raise ModelCallUnavailable("model_call_attempt_unavailable") + self._require_execution_slot(execution, _slot_key(slot)) + await self._repo.lock_slot_scope(_slot_key(slot)) + row = await self._repo.attempt(effect.attempt_id, lock=True) + if row is None or row.state != "completed": + raise ModelCallUnavailable("model_call_completion_unavailable") + _require_effect(effect, slot, row) + existing = await self._repo.recovery( + effect.attempt_id, + execution.checker_run_id, + execution.worker_lease_id, + execution.worker_lease_generation, + ) + if existing is None: + await self._repo.add( + CheckerModelCallRecovery( + id=str(new_record_id()), + attempt_id=str(effect.attempt_id), + checker_run_id=str(execution.checker_run_id), + worker_lease_id=str(execution.worker_lease_id), + worker_lease_generation=execution.worker_lease_generation, + authorization_evidence_id=str(authority.evidence_id), + ) + ) + return self._completed_result(row, replayed=existing is not None) + + async def mark_uncertain( + self, + effect: ModelCallEffectAuthorityFacts, + uncertainty_digest: str, + *, + provider_response_id: str | None = None, + ) -> ModelDispatchReservation: + """Conserve the full reservation after an ambiguous dispatched effect.""" + self._require_transaction() + effect = ModelCallEffectAuthorityFacts.model_validate(effect) + uncertainty_digest = _sha(uncertainty_digest) + if provider_response_id is not None: + provider_response_id = _PROVIDER_RESPONSE_ADAPTER.validate_python( + provider_response_id, strict=True + ) + authority = await self._authority.authorize_observe( + effect, provider_response_id, uncertainty_digest + ) + observation_facts = { + "effect": effect.model_dump(mode="json"), + "provider_response_id": provider_response_id, + "observation_digest": uncertainty_digest, + } + _require_authority("observe", observation_facts, authority) + _, slot, _ = await self._terminal_preflight(effect.attempt_id) + row = await self._lock_terminal(slot, effect.attempt_id) + _require_effect(effect, slot, row) + if row.state == "uncertain": + if ( + row.uncertainty_digest != uncertainty_digest + or row.provider_response_id != provider_response_id + ): + raise ModelCallConflict("model_call_uncertainty_conflict") + return self._dispatch_result(row, slot=slot, replayed=True) + if row.state != "dispatching": + raise ModelCallConflict("model_call_uncertainty_conflict") + row.state = "uncertain" + row.uncertainty_digest = uncertainty_digest + row.provider_response_id = provider_response_id + row.observation_authorization_evidence_id = str(authority.evidence_id) + await self._repo.flush() + return self._dispatch_result(row, slot=slot, replayed=False) + + async def complete( + self, + effect: ModelCallEffectAuthorityFacts, + *, + provider_response_id: str, + provider_model_snapshot: str, + response_evidence: ModelCallEvidence, + usage: ModelCallUsage, + ) -> CompletedModelCall: + """Store verified usable response evidence and settle cost exactly once.""" + effect = ModelCallEffectAuthorityFacts.model_validate(effect) + attempt_id = effect.attempt_id + provider_response_id = _PROVIDER_RESPONSE_ADAPTER.validate_python( + provider_response_id, strict=True + ) + response_evidence = ModelCallEvidence.model_validate(response_evidence) + usage = ModelCallUsage.model_validate(usage) + terminal = { + "status": "completed", + "provider_response_id": provider_response_id, + "provider_model_snapshot": provider_model_snapshot, + "response_evidence": response_evidence.model_dump(mode="json"), + "usage": usage.model_dump(mode="json"), + } + terminal_digest = canonical_json_hash(terminal) + authority = await self._authority.authorize_complete( + effect, response_evidence, terminal_digest + ) + _require_authority( + "complete", + { + "effect": effect.model_dump(mode="json"), + "response_evidence": response_evidence.model_dump(mode="json"), + "terminal_digest": terminal_digest, + }, + authority, + ) + await self._evidence.require_response(attempt_id, response_evidence) + row, slot, profile = await self._terminal_preflight(attempt_id) + if ( + provider_model_snapshot != profile.spec.model_snapshot + or response_evidence.byte_count > profile.spec.maximum_response_bytes + ): + raise ModelCallConflict("model_call_response_conflict") + cost = model_usage_cost(profile.spec, usage) + row = await self._lock_terminal(slot, attempt_id) + _require_effect(effect, slot, row) + if row.state == "completed": + if ( + row.provider_response_id != provider_response_id + or _evidence(row, "response") != response_evidence + or _usage(row) != usage + or row.settled_nanodollars != cost + ): + raise ModelCallConflict("model_call_terminal_conflict") + return self._completed_result(row, replayed=True) + self._require_effect_pending(row) + if ( + row.provider_response_id is not None + and row.provider_response_id != provider_response_id + ): + raise ModelCallConflict("model_call_provider_response_conflict") + await self._settle(row, slot, cost) + self._set_evidence(row, "response", response_evidence) + self._set_usage(row, usage) + row.provider_response_id = provider_response_id + row.settled_nanodollars = cost + row.terminal_authorization_evidence_id = str(authority.evidence_id) + row.terminal_at = await self._repo.now() + row.state = "completed" + await self._repo.flush() + return self._completed_result(row, replayed=False) + + async def complete_known_failure( + self, + effect: ModelCallEffectAuthorityFacts, + *, + provider_response_id: str, + provider_model_snapshot: str, + response_evidence: ModelCallEvidence, + usage: ModelCallUsage, + ) -> KnownFailedModelCall: + """Record and settle a terminal failed effect with complete authenticated usage.""" + effect = ModelCallEffectAuthorityFacts.model_validate(effect) + attempt_id = effect.attempt_id + provider_response_id = _PROVIDER_RESPONSE_ADAPTER.validate_python( + provider_response_id, strict=True + ) + response_evidence = ModelCallEvidence.model_validate(response_evidence) + usage = ModelCallUsage.model_validate(usage) + terminal = { + "status": "known_failed", + "provider_response_id": provider_response_id, + "provider_model_snapshot": provider_model_snapshot, + "response_evidence": response_evidence.model_dump(mode="json"), + "usage": usage.model_dump(mode="json"), + } + terminal_digest = canonical_json_hash(terminal) + authority = await self._authority.authorize_complete( + effect, response_evidence, terminal_digest + ) + _require_authority( + "complete", + { + "effect": effect.model_dump(mode="json"), + "response_evidence": response_evidence.model_dump(mode="json"), + "terminal_digest": terminal_digest, + }, + authority, + ) + await self._evidence.require_response(attempt_id, response_evidence) + row, slot, profile = await self._terminal_preflight(attempt_id) + if ( + provider_model_snapshot != profile.spec.model_snapshot + or response_evidence.byte_count > profile.spec.maximum_response_bytes + ): + raise ModelCallConflict("model_call_response_conflict") + cost = model_usage_cost(profile.spec, usage) + row = await self._lock_terminal(slot, attempt_id) + _require_effect(effect, slot, row) + if row.state == "known_failed": + stored_usage = _usage(row) + same_response = ( + row.provider_response_id == provider_response_id + and _evidence(row, "response") == response_evidence + ) + if not same_response or stored_usage != usage or row.settled_nanodollars != cost: + raise ModelCallConflict("model_call_terminal_conflict") + return self._failed_result(row, replayed=True) + self._require_effect_pending(row) + if ( + row.provider_response_id is not None + and row.provider_response_id != provider_response_id + ): + raise ModelCallConflict("model_call_provider_response_conflict") + await self._settle(row, slot, cost) + self._set_usage(row, usage) + row.settled_nanodollars = cost + row.settlement_authorization_evidence_id = str(authority.evidence_id) + row.settled_at = await self._repo.now() + self._set_evidence(row, "response", response_evidence) + row.provider_response_id = provider_response_id + row.terminal_authorization_evidence_id = str(authority.evidence_id) + row.terminal_at = await self._repo.now() + row.state = "known_failed" + await self._repo.flush() + return self._failed_result(row, replayed=False) + + async def mark_known_no_effect( + self, effect: ModelCallEffectAuthorityFacts, proof: KnownNoEffectProof + ) -> AttachedModelCallAttempt: + """Release reservation only for authenticated proof that create had no effect.""" + effect = ModelCallEffectAuthorityFacts.model_validate(effect) + attempt_id = effect.attempt_id + proof = KnownNoEffectProof.model_validate(proof) + terminal_digest = model_retry_terminal_digest(proof) + authority = await self._authority.authorize_complete(effect, None, terminal_digest) + _require_authority( + "complete", + { + "effect": effect.model_dump(mode="json"), + "response_evidence": None, + "terminal_digest": terminal_digest, + }, + authority, + ) + _, slot, _ = await self._terminal_preflight(attempt_id) + row = await self._lock_terminal(slot, attempt_id) + _require_effect(effect, slot, row) + if row.state == "known_no_effect": + if ( + row.no_effect_proof_id != str(proof.proof_id) + or row.no_effect_proof_digest != proof.proof_digest + or row.no_effect_observed_at != proof.observed_at + ): + raise ModelCallConflict("model_call_terminal_conflict") + return _attempt(slot, row, replayed=True) + self._require_effect_pending(row) + if row.provider_response_id is not None: + raise ModelCallConflict("model_call_no_effect_conflict") + await self._release(row, slot) + row.no_effect_proof_id = str(proof.proof_id) + row.no_effect_proof_digest = proof.proof_digest + row.no_effect_observed_at = proof.observed_at + row.terminal_authorization_evidence_id = str(authority.evidence_id) + row.terminal_at = await self._repo.now() + row.state = "known_no_effect" + await self._repo.flush() + return _attempt(slot, row, replayed=False) + + async def _terminal_preflight( + self, attempt_id: UUID + ) -> tuple[CheckerModelCallAttempt, CheckerModelCallSlot, ProjectModelProfile]: + self._require_transaction() + row = await self._repo.attempt(attempt_id) + if row is None: + raise ModelCallUnavailable("model_call_attempt_unavailable") + slot = await self._session.get(CheckerModelCallSlot, row.slot_id) + if slot is None: + raise ModelCallUnavailable("model_call_attempt_unavailable") + profile_row = await self._repo.profile_exact( + _uuid(slot.profile_id), slot.profile_version, slot.profile_digest + ) + if profile_row is None: + raise ModelCallUnavailable("model_profile_unavailable") + return row, slot, stored_model_profile(profile_row) + + async def _lock_terminal( + self, slot: CheckerModelCallSlot, attempt_id: UUID + ) -> CheckerModelCallAttempt: + await self._repo.lock_slot_scope(_slot_key(slot)) + row = await self._repo.attempt(attempt_id, lock=True) + if row is None or row.slot_id != slot.id: + raise ModelCallUnavailable("model_call_attempt_unavailable") + return row + + @staticmethod + def _require_effect_pending(row: CheckerModelCallAttempt) -> None: + if row.state not in {"dispatching", "uncertain"}: + raise ModelCallConflict("model_call_terminal_conflict") + if row.reserved_nanodollars is None: + raise ModelCallUnavailable("model_call_reservation_unavailable") + + async def _settle( + self, + row: CheckerModelCallAttempt, + slot: CheckerModelCallSlot, + cost: int, + ) -> None: + await self._repo.lock_budget_scope(_uuid(slot.project_id)) + account = await self._repo.budget_account(_uuid(slot.project_id), lock=True) + if account is None or row.reserved_nanodollars is None: + raise ModelCallUnavailable("model_budget_unavailable") + if account.lifetime_reserved_nanodollars < row.reserved_nanodollars: + raise ModelCallUnavailable("model_budget_conservation_failed") + account.lifetime_reserved_nanodollars -= row.reserved_nanodollars + account.lifetime_settled_nanodollars += cost + + async def _release(self, row: CheckerModelCallAttempt, slot: CheckerModelCallSlot) -> None: + await self._repo.lock_budget_scope(_uuid(slot.project_id)) + account = await self._repo.budget_account(_uuid(slot.project_id), lock=True) + if account is None or row.reserved_nanodollars is None: + raise ModelCallUnavailable("model_budget_unavailable") + if account.lifetime_reserved_nanodollars < row.reserved_nanodollars: + raise ModelCallUnavailable("model_budget_conservation_failed") + account.lifetime_reserved_nanodollars -= row.reserved_nanodollars + + @staticmethod + def _set_usage(row: CheckerModelCallAttempt, usage: ModelCallUsage) -> None: + row.input_tokens = usage.input_tokens + row.cached_input_tokens = usage.cached_input_tokens + row.output_tokens = usage.output_tokens + row.reasoning_tokens = usage.reasoning_tokens + + @staticmethod + def _completed_result(row: CheckerModelCallAttempt, *, replayed: bool) -> CompletedModelCall: + usage = _usage(row) + if usage is None or row.terminal_at is None or row.settled_nanodollars is None: + raise ModelCallUnavailable("model_call_completion_unavailable") + return CompletedModelCall( + slot_id=_uuid(row.slot_id), + attempt_id=_uuid(row.id), + attempt_number=row.attempt_number, + internal_call_request_id=_uuid(row.internal_call_request_id), + provider_response_id=row.provider_response_id, + request_evidence=_evidence(row, "request"), + response_evidence=_evidence(row, "response"), + usage=usage, + settled_nanodollars=row.settled_nanodollars, + completed_at=row.terminal_at, + replayed=replayed, + ) + + @staticmethod + def _failed_result(row: CheckerModelCallAttempt, *, replayed: bool) -> KnownFailedModelCall: + usage = _usage(row) + if row.terminal_at is None or usage is None or row.settled_nanodollars is None: + raise ModelCallUnavailable("model_call_completion_unavailable") + return KnownFailedModelCall( + slot_id=_uuid(row.slot_id), + attempt_id=_uuid(row.id), + attempt_number=row.attempt_number, + internal_call_request_id=_uuid(row.internal_call_request_id), + provider_response_id=row.provider_response_id, + request_evidence=_evidence(row, "request"), + response_evidence=_evidence(row, "response"), + usage=usage, + settled_nanodollars=row.settled_nanodollars, + completed_at=row.terminal_at, + replayed=replayed, + ) + + +__all__ = ("ModelCallService",) diff --git a/backend/app/modules/checkers/router.py b/backend/app/modules/checkers/router.py index 6d22de255..001fe6b3b 100644 --- a/backend/app/modules/checkers/router.py +++ b/backend/app/modules/checkers/router.py @@ -25,6 +25,13 @@ get_post_submit_delivery_commands, get_post_submit_delivery_reads, require_post_submit_delivery_human, ) +from app.api.deps.checker_models import ( + MODEL_ADMIN_IDEMPOTENCY_PARAMETER, + CheckerModelAdminCommands, + get_checker_model_admin_commands, + require_model_admin_human, + require_model_admin_key, +) from app.api.deps.history import HistoryReadOperation, get_history_reads @@ -48,11 +55,98 @@ PostSubmitDeliveryRequest, PostSubmitDeliveryResult, ) +from app.modules.checkers.api.model_calls import ( + ModelRecoveryChildRequest, + PreparedModelCallAttempt, + ProjectModelBudgetConfiguration, + ProjectModelBudgetConfigurationSpec, + ProjectModelProfile, + ProjectModelProfileSpec, +) router = APIRouter(tags=["checkers"]) +@router.post( + "/operations/projects/{project_id}/model-profiles", + response_model=ProjectModelProfile, + status_code=status.HTTP_201_CREATED, + dependencies=[ + Depends(enforce_admin_mutation_rate_limit), + Depends(require_model_admin_human), + Depends(require_model_admin_key), + ], + responses={code: {"model": ApiErrorResponse} for code in (404, 409, 422, 503)}, + openapi_extra={ + "x-workstream-action-id": "project.model_profile.provision", + "parameters": [MODEL_ADMIN_IDEMPOTENCY_PARAMETER], + }, +) +async def provision_project_model_profile( + project_id: UUID, + payload: ProjectModelProfileSpec, + commands: Annotated[ + CheckerModelAdminCommands, Depends(get_checker_model_admin_commands) + ], +) -> ProjectModelProfile: + """Publish or exactly replay one immutable approved project model profile.""" + return await commands.provision_profile(project_id, payload) + + +@router.post( + "/operations/projects/{project_id}/model-budget-configurations", + response_model=ProjectModelBudgetConfiguration, + status_code=status.HTTP_201_CREATED, + dependencies=[ + Depends(enforce_admin_mutation_rate_limit), + Depends(require_model_admin_human), + Depends(require_model_admin_key), + ], + responses={code: {"model": ApiErrorResponse} for code in (404, 409, 422, 503)}, + openapi_extra={ + "x-workstream-action-id": "project.model_budget.configure", + "parameters": [MODEL_ADMIN_IDEMPOTENCY_PARAMETER], + }, +) +async def configure_project_model_budget( + project_id: UUID, + payload: ProjectModelBudgetConfigurationSpec, + commands: Annotated[ + CheckerModelAdminCommands, Depends(get_checker_model_admin_commands) + ], +) -> ProjectModelBudgetConfiguration: + """Create or replay one immutable lifetime project model budget version.""" + return await commands.configure_budget(project_id, payload) + + +@router.post( + "/operations/projects/{project_id}/model-call-attempts/{parent_attempt_id}/recovery-children", + response_model=PreparedModelCallAttempt, + status_code=status.HTTP_201_CREATED, + dependencies=[ + Depends(enforce_admin_mutation_rate_limit), + Depends(require_model_admin_human), + Depends(require_model_admin_key), + ], + responses={code: {"model": ApiErrorResponse} for code in (404, 409, 422, 503)}, + openapi_extra={ + "x-workstream-action-id": "project.model_call.recovery_child.authorize", + "parameters": [MODEL_ADMIN_IDEMPOTENCY_PARAMETER], + }, +) +async def prepare_project_model_recovery_child( + project_id: UUID, + parent_attempt_id: UUID, + payload: ModelRecoveryChildRequest, + commands: Annotated[ + CheckerModelAdminCommands, Depends(get_checker_model_admin_commands) + ], +) -> PreparedModelCallAttempt: + """Create or replay a child only for one exact known terminal paid effect.""" + return await commands.prepare_recovery_child(project_id, parent_attempt_id, payload) + + @router.post( "/operations/external-checker-registry", response_model=ExternalCheckerRegistryEntry, diff --git a/backend/app/modules/projects/guide_activation/service.py b/backend/app/modules/projects/guide_activation/service.py index 6a1dfd876..c1b42824d 100644 --- a/backend/app/modules/projects/guide_activation/service.py +++ b/backend/app/modules/projects/guide_activation/service.py @@ -9,9 +9,12 @@ from app.interfaces.project_agents import RequirementDisposition from app.modules.authorization.api import ActorKind from app.modules.checkers.api.external_policy import ( + ExternalCheckerPolicyError, + require_locked_model_profiles, validate_stored_external_checker_policies, ) from app.modules.checkers.api.external import ExternalCheckerRegistryPort +from app.modules.checkers.api.model_calls import ModelProfileReaderPort from app.modules.projects.api.guide_activation import ( GuideActivationCommand, GuideActivationCondition, @@ -53,6 +56,7 @@ def __init__( contribution, authorization=None, registry: ExternalCheckerRegistryPort | None = None, + profiles: ModelProfileReaderPort | None = None, ): self.session = session self.authorization = authorization @@ -61,6 +65,7 @@ def __init__( self.replay = GuideMutationRepository(session) self.post = PostPolicyRepository(session) self.registry = registry + self.profiles = profiles async def activate( self, command: GuideActivationCommand, *, actor, request_id: UUID @@ -124,7 +129,11 @@ async def activate( command.contribution_policy_version_id, ), ) - guide, previous, contribution = checked.guide, checked.previous, checked.contribution + guide, previous, contribution = ( + checked.guide, + checked.previous, + checked.contribution, + ) operation_id = new_record_id() receipt = GuideActivationReceipt( operation_id=operation_id, @@ -232,7 +241,10 @@ async def readiness( expected=None, contribution_selection=None if published is None - else (published.contribution_policy_id, published.contribution_policy_version_id), + else ( + published.contribution_policy_id, + published.contribution_policy_version_id, + ), ) except GuideProposalError as exc: if exc.code not in _PRODUCT_REFUSALS: @@ -277,8 +289,12 @@ async def _evaluate( # The lock admits only a draft guide; name that cause with the # lock's own refusal code, which activation raises unchanged. guide = await self.projects.get_guide(str(selection.guide_id)) - conditions.fail(exc.code, "guide_not_draft" if guide is not None and guide.status != "draft" - else "post_policy_unavailable") + conditions.fail( + exc.code, + "guide_not_draft" + if guide is not None and guide.status != "draft" + else "post_policy_unavailable", + ) return None try: await self.post.require_current_upstream(locked) @@ -364,6 +380,14 @@ async def _readiness(self, conditions, locked, policy, post, review, revision): ) if policies.pre_submit.model_dump(mode="json") != approval.pre.compiled_bundle: conditions.fail("approval_blocked", "checker_policy_mismatch") + try: + await require_locked_model_profiles( + policies, + project_id=UUID(locked.view.guide.project_id), + profiles=self.profiles, + ) + except ExternalCheckerPolicyError: + conditions.fail("approval_blocked", "checker_model_profile_unavailable") for binding in (*policies.pre_submit.entries, *policies.post_submit.entries): current = await self.registry.read_exact( binding.registry.registry_entry_id, diff --git a/backend/app/modules/projects/guide_compilation/proposal_approval.py b/backend/app/modules/projects/guide_compilation/proposal_approval.py index 410ba637d..34d6badb8 100644 --- a/backend/app/modules/projects/guide_compilation/proposal_approval.py +++ b/backend/app/modules/projects/guide_compilation/proposal_approval.py @@ -18,6 +18,7 @@ from app.modules.checkers.api.policy_custody import CompiledPreSubmitCheckerPolicy from app.modules.checkers.api.external import ExternalCheckerRegistryPort from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection +from app.modules.checkers.api.model_calls import ModelProfileReaderPort from app.modules.checkers.api.external_policy import ( LockedExternalCheckerPolicies, compile_external_checker_policy_bundle, @@ -64,6 +65,7 @@ async def approve_proposal( material, capabilities: ExternalCheckerCapabilityProjection, registry: ExternalCheckerRegistryPort, + profiles: ModelProfileReaderPort | None = None, ) -> GuideProposalApprovalReceipt: """Compile and persist in the caller transaction after exact authority closes.""" target = command.target @@ -110,6 +112,7 @@ async def approve_proposal( material, capabilities, registry, + profiles, ) receipt = outputs.receipt facts = _facts(locator, target, request_digest, receipt, command) @@ -207,6 +210,7 @@ async def _compile_approval( material, capabilities, registry, + profiles, ) -> _CompiledApproval: target = command.target inputs = CompilationRequestInputs( @@ -220,7 +224,12 @@ async def _compile_approval( locked.view.policy.policy_body, ) effective_hash = canonical_json_hash(effective_body) - policies = await compile_external_checker_policy_bundle(locked.result, registry) + policies = await compile_external_checker_policy_bundle( + locked.result, + registry, + project_id=target.project_id, + profiles=profiles, + ) external_pre = policies.pre_submit compiled = CompiledPreSubmitCheckerPolicy( compiler_version="external_checker_policy.v1", diff --git a/backend/app/modules/projects/guide_compilation/proposal_service.py b/backend/app/modules/projects/guide_compilation/proposal_service.py index 00374e2a3..61c3cb63b 100644 --- a/backend/app/modules/projects/guide_compilation/proposal_service.py +++ b/backend/app/modules/projects/guide_compilation/proposal_service.py @@ -32,6 +32,7 @@ from app.modules.projects.api.guide_documents import GuideDocumentManifestPort from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection from app.modules.checkers.api.external import ExternalCheckerRegistryPort +from app.modules.checkers.api.model_calls import ModelProfileReaderPort from app.modules.projects.api.guide_proposal_package import GuideProposalReviewPackage from .proposal_repository import GuideProposalRepository @@ -46,11 +47,13 @@ def __init__( session: AsyncSession, authorization: GuideProposalAuthorizationPort, registry: ExternalCheckerRegistryPort | None = None, + profiles: ModelProfileReaderPort | None = None, ) -> None: self.session = session self.authorization = authorization self.repository = GuideProposalRepository(session) self.registry = registry + self.profiles = profiles async def review_package( self, @@ -120,6 +123,7 @@ async def approve( material=material, capabilities=capabilities, registry=self.registry, + profiles=self.profiles, ) def _require_transaction(self) -> None: diff --git a/backend/app/modules/tasks/post_submit_routing/evaluation_guard.py b/backend/app/modules/tasks/post_submit_routing/evaluation_guard.py index 45665f0a8..4f531cff0 100644 --- a/backend/app/modules/tasks/post_submit_routing/evaluation_guard.py +++ b/backend/app/modules/tasks/post_submit_routing/evaluation_guard.py @@ -7,6 +7,7 @@ from app.modules.checkers.api.execution import CheckerExecutionUnavailable from app.modules.checkers.api.evaluation_contract import PostSubmissionExecutionRequest +from app.modules.checkers.api.output_custody import CheckerOutputSelector, CheckerOutputUnavailable from app.modules.tasks.api.submitted_bundle import SubmittedBundleRequest, SubmittedBundleUnavailable from app.modules.tasks.repository import TaskRepository from app.modules.tasks.submitted_bundle import SubmittedBundleReader @@ -43,6 +44,27 @@ def __init__(self, session: AsyncSession): self._repository = TaskRepository(session) self._submitted = SubmittedBundleReader(session) + async def lock_model_evidence_scope(self, selector: CheckerOutputSelector) -> None: + """Retain exact historical owners before private model evidence FK writes.""" + slot = selector.paid_slot + task = await self._repository.lock_project_task(slot.project_id, selector.task_id) + if task is None: + raise CheckerOutputUnavailable("checker_output_task_unavailable") + assignment = await self._repository.lock_accepted_assignment( + project_id=slot.project_id, task_id=selector.task_id, + assignment_id=slot.assignment_id, + ) + submission = await self._repository.lock_exact_submission_for_task( + project_id=slot.project_id, task_id=selector.task_id, + submission_id=slot.submission_id, + ) + if assignment is None or submission is None or ( + submission.version != slot.submission_version + or submission.task_assignment_id != str(slot.assignment_id) + or submission.contributor_id != assignment.contributor_id + ): + raise CheckerOutputUnavailable("checker_output_task_unavailable") + async def lock_evaluation_scope(self, request: PostSubmissionExecutionRequest) -> bool: """Return new-generation eligibility after exact Task/Assignment/Submission locks.""" task = await self._repository.lock_project_task(request.project_id, request.task_id) diff --git a/backend/app/workers/checker_model_proxy.py b/backend/app/workers/checker_model_proxy.py new file mode 100644 index 000000000..18e4f6d27 --- /dev/null +++ b/backend/app/workers/checker_model_proxy.py @@ -0,0 +1,31 @@ +"""Singleton private model-proxy process; durable effects live in CHECKERS and ART.""" + +import asyncio +import signal + +from app.adapters.checkers.model_runtime import configured_model_proxy_runtime +from app.core.config import get_settings +from app.db.session import dispose_engine, get_session_factory + + +async def serve() -> None: + """Serve the configured private socket until termination, then drain admitted work.""" + settings = get_settings() + if settings.checker_model_proxy_socket is None: + raise RuntimeError("checker model proxy socket is required") + stop = asyncio.Event() + loop = asyncio.get_running_loop() + signals = (signal.SIGINT, signal.SIGTERM) + for signum in signals: + loop.add_signal_handler(signum, stop.set) + try: + async with configured_model_proxy_runtime(settings, get_session_factory()): + await stop.wait() + finally: + for signum in signals: + loop.remove_signal_handler(signum) + await dispose_engine() + + +if __name__ == "__main__": + asyncio.run(serve()) diff --git a/backend/scripts/external_api_drill.py b/backend/scripts/external_api_drill.py index 354a8df7b..d7d8d5fec 100644 --- a/backend/scripts/external_api_drill.py +++ b/backend/scripts/external_api_drill.py @@ -97,6 +97,11 @@ def guide_metadata(response): project.guide_compilation.execute project.effective_policy.manage project.task.manage project.review_policy.manage project.role_grant.read project.role_grant.manage task.queue.read task.claim submission.create submission.read_own submission.read_for_review +task.post_submit.route task.revision.expire task.revision.recover +checker.pre_submit.execute checker.pre_submit.finalize +checker.post_submit.execute checker.post_submit.finalize +checker.model_call.prepare checker.model_call.dispatch checker.model_call.recover +checker.model_call.observe checker.model_call.complete checker.model_call.grant review.queue.read review.queue.inspect review.claim review.release review.decline_preference review.decision review.lease.force_release review.chain.read review.queue.override contribution.read_self contribution.read_project compensation.policy.manage @@ -109,6 +114,7 @@ def guide_metadata(response): artifact.audit.read artifact.guide_source.ingest artifact.binding.create artifact.verification.execute artifact.pending_work.scan artifact.put_attempt.resolve artifact.guide_source.read artifact.checker_input.materialize artifact.checker_output.write artifact.review_packet.materialize +project.model_profile.manage project.model_budget.manage project.model_call.recovery_child.authorize audit.read audit.export """.split())) EXPECTED_ROLE_CONTRACT = ( @@ -119,6 +125,7 @@ def guide_metadata(response): review.lease.force_release contribution.read_project operations.status.read operations.timer.run operations.reconcile.run operations.outbox.retry operations.projection.rebuild operations.task.start_override operations.submission_gate.repair operations.checker.retry +project.model_profile.manage project.model_budget.manage project.model_call.recovery_child.authorize artifact.binding.read artifact.replica.read artifact.receipt.read artifact.verification_job.read artifact.verification_job.retry artifact.recovery_attempt.read artifact.audit.read audit.read"""), ("project_manager", ("system", "project"), """project.create project.read project.setup_diagnostic.read project.effective_policy.read project.update @@ -136,7 +143,7 @@ def guide_metadata(response): def catalogue_expectations(): """Fresh expected JSON objects; callers cannot mutate the frozen oracle.""" return { - "permissions": {"items": [{"permission_id": value} for value in EXPECTED_PERMISSIONS], "total": 75}, + "permissions": {"items": [{"permission_id": value} for value in EXPECTED_PERMISSIONS], "total": 91}, "admin-role-definitions": {"items": [ {"role": role, "allowed_scopes": list(scopes), "permission_ids": permissions.split()} for role, scopes, permissions in EXPECTED_ROLE_CONTRACT], "total": 5}, diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index ed6e69463..7a7498e84 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -482,6 +482,17 @@ class TestLane: # Checker delivery shares project dispatch ownership across the project family. CHECKER_DELIVERY_MODULES = ( + "tests/checkers_model_calls/test_composed_execution.py", + "tests/checkers_model_calls/test_model_call_contract.py", + "tests/checkers_model_calls/test_model_call_transactions_postgresql.py", + "tests/checkers_model_calls/test_model_invocation_grants_postgresql.py", + "tests/checkers_model_calls/test_model_runtime.py", + "tests/checkers_model_calls/test_proxy_orchestration.py", + "tests/checkers_model_calls/test_public_api.py", + "tests/checkers/model_provider/test_openai_responses.py", + "tests/checkers/external/test_model_transport.py", + "tests/checkers/external/test_model_proxy_transport.py", + "tests/authorization/checker_models/test_contracts.py", "tests/checkers/default/test_contract.py", "tests/checkers/default/test_execution.py", "tests/checkers/external/test_contracts.py", diff --git a/backend/tests/authorization/admin_access/test_admin_reads_postgresql.py b/backend/tests/authorization/admin_access/test_admin_reads_postgresql.py index b923a0b8f..1478ed9cd 100644 --- a/backend/tests/authorization/admin_access/test_admin_reads_postgresql.py +++ b/backend/tests/authorization/admin_access/test_admin_reads_postgresql.py @@ -183,7 +183,7 @@ async def test_missing_admin_target_has_concealed_response_and_no_success_effect assert await authority_snapshot() == before -@pytest.mark.parametrize("surface,total", [("permissions", 82), ("admin-role-definitions", 5)]) +@pytest.mark.parametrize("surface,total", [("permissions", 91), ("admin-role-definitions", 5)]) async def test_admin_catalogue_read_returns_public_definitions( admin_access: AdminAccess, surface: str, diff --git a/backend/tests/authorization/admin_access/test_grant_reads_postgresql.py b/backend/tests/authorization/admin_access/test_grant_reads_postgresql.py index 1fa316b55..53372d923 100644 --- a/backend/tests/authorization/admin_access/test_grant_reads_postgresql.py +++ b/backend/tests/authorization/admin_access/test_grant_reads_postgresql.py @@ -92,7 +92,7 @@ async def test_system_auditor_has_exact_read_surface( assert response.status_code == 200, response.text body = response.json() if surface in {"permissions", "definitions"}: - assert body["total"] == len(body["items"]) == (82 if surface == "permissions" else 5) + assert body["total"] == len(body["items"]) == (91 if surface == "permissions" else 5) elif surface in {"list", "history"}: expected = {grant_id, access.bootstrap_grant_id} if surface == "list" else {grant_id} assert body["total"] == len(expected) diff --git a/backend/tests/authorization/catalogue_fixtures.py b/backend/tests/authorization/catalogue_fixtures.py index 5d884e5d5..529174cc2 100644 --- a/backend/tests/authorization/catalogue_fixtures.py +++ b/backend/tests/authorization/catalogue_fixtures.py @@ -25,7 +25,16 @@ "checker.pre_submit.execute", "checker.pre_submit.finalize", }, - ServiceIdentity.CHECKER_POST_SUBMIT: {"checker.post_submit.execute", "checker.post_submit.finalize"}, + ServiceIdentity.CHECKER_POST_SUBMIT: { + "checker.post_submit.execute", + "checker.post_submit.finalize", + "checker.model_call.prepare", + "checker.model_call.dispatch", + "checker.model_call.recover", + "checker.model_call.observe", + "checker.model_call.complete", + "checker.model_call.grant", + }, ServiceIdentity.ARTIFACT_CHECKER_OUTPUT: {"artifact.checker_output.write"}, ServiceIdentity.PROJECT_SETUP: { "project.guide_compilation.request_automatic", @@ -131,7 +140,7 @@ "artifact.checker_output.write": ( "artifact.checker_output.write", "WS-AUTH-001-ART-06B", - "planned", + "active", ), "artifact.review_packet.materialize": ( "artifact.review_packet.materialize", @@ -146,7 +155,7 @@ "artifact.checker_output.binding.create": ( "artifact.binding.create", "WS-AUTH-001-ART-06B", - "planned", + "active", ), } @@ -262,7 +271,11 @@ artifact.verification.execute artifact.pending_work.scan artifact.put_attempt.resolve artifact.guide_source.read artifact.checker_input.materialize artifact.checker_output.write artifact.review_packet.materialize - review.queue.override project.guide_compilation.request project.guide_compilation.execute""".split() + review.queue.override project.guide_compilation.request project.guide_compilation.execute + checker.model_call.prepare checker.model_call.dispatch checker.model_call.recover + checker.model_call.observe checker.model_call.complete checker.model_call.grant + project.model_profile.manage project.model_budget.manage + project.model_call.recovery_child.authorize""".split() ) expected = { @@ -289,6 +302,18 @@ "checker.pre_submit.finalize": ("checker.pre_submit.finalize", "PILOT-04"), "checker.post_submit.execute": ("checker.post_submit.execute", "WS-ARCH-001-04D2"), "checker.post_submit.finalize": ("checker.post_submit.finalize", "WS-ARCH-001-04D2"), + "checker.model_call.prepare": ("checker.model_call.prepare", "PILOT-05"), + "checker.model_call.dispatch": ("checker.model_call.dispatch", "PILOT-05"), + "checker.model_call.recover": ("checker.model_call.recover", "PILOT-05"), + "checker.model_call.observe": ("checker.model_call.observe", "PILOT-05"), + "checker.model_call.complete": ("checker.model_call.complete", "PILOT-05"), + "checker.model_call.grant": ("checker.model_call.grant", "PILOT-05"), + "project.model_profile.provision": ("project.model_profile.manage", "PILOT-05"), + "project.model_budget.configure": ("project.model_budget.manage", "PILOT-05"), + "project.model_call.recovery_child.authorize": ( + "project.model_call.recovery_child.authorize", + "PILOT-05", + ), "checker.registry.register": ("operations.reconcile.run", "PILOT-04"), "checker.registry.read": ("operations.status.read", "PILOT-04"), "task.submission.list": ("submission.read_own", "task-checker-auth-cleanup"), diff --git a/backend/tests/authorization/checker_models/test_contracts.py b/backend/tests/authorization/checker_models/test_contracts.py new file mode 100644 index 000000000..23347a77e --- /dev/null +++ b/backend/tests/authorization/checker_models/test_contracts.py @@ -0,0 +1,280 @@ +"""Discriminating pure contracts for checker model and output authority.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +import pytest +from pydantic import ValidationError + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.actors.api import ServiceIdentity +from app.modules.authorization.catalogue import ActionId, PermissionId +from app.modules.authorization.checker_model_authorization import CheckerModelAuthorization +from app.modules.authorization.domain.checker_models import ( + GRANT, + RECOVER, + ModelCallGrantAuthorityFacts, + ModelCallRecoverAuthorityFacts, + checker_model_call_resource, +) +from app.modules.authorization.domain.checker_outputs import ( + BIND, + WRITE, + checker_output_prepare_matches, + checker_output_prepare_resource, + checker_output_stored_resource, +) +from app.modules.authorization.prepared import PreparedAuthorizationHandle +from app.modules.authorization.runtime import ( + ActorKind, + ActorStatus, + AuthorizationDecision, + IdentityLinkStatus, + MatchedAuthorityKind, + ServiceAuthorizationContext, +) +from app.modules.checkers.api.model_calls import ( + ModelCallEffectAuthorityFacts, + ModelCallExecutionFacts, + ModelCallUnavailable, + ModelPaidSlotKey, +) +from app.modules.checkers.api.output_custody import ( + CheckerOutputReservation, + CheckerOutputSelector, + CheckerOutputSlot, +) + + +def sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +def execution(*, project_id=None, task_id=None) -> ModelCallExecutionFacts: + return ModelCallExecutionFacts( + project_id=project_id or new_record_id(), + task_id=task_id or new_record_id(), + assignment_id=new_record_id(), + submission_id=new_record_id(), + submission_version=1, + evaluation_request_id=new_record_id(), + evaluation_generation=1, + checker_run_id=new_record_id(), + worker_lease_id=new_record_id(), + worker_lease_generation=1, + worker_lease_expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + + +def paid_slot(current: ModelCallExecutionFacts) -> ModelPaidSlotKey: + return ModelPaidSlotKey( + project_id=current.project_id, + assignment_id=current.assignment_id, + submission_id=current.submission_id, + submission_version=current.submission_version, + requirement_id="model_quality", + registry_entry_digest=sha("registry"), + configuration_sha256=sha("configuration"), + profile_id=new_record_id(), + profile_version=1, + profile_digest=sha("profile"), + ) + + +def effect(current: ModelCallExecutionFacts, slot: ModelPaidSlotKey): + return ModelCallEffectAuthorityFacts( + project_id=current.project_id, + task_id=current.task_id, + slot_id=new_record_id(), + slot_key=slot, + attempt_id=new_record_id(), + internal_call_request_id=new_record_id(), + ) + + +def selector(current: ModelCallExecutionFacts, slot: ModelPaidSlotKey, *, role="model_request"): + return CheckerOutputSelector( + paid_slot=slot, + task_id=current.task_id, + model_call_slot_id=new_record_id(), + model_call_attempt_id=new_record_id(), + slot_key=role, + content_sha256=sha(role), + execution=current if role == "model_request" else None, + ) + + +def test_recovery_resource_rejects_substituted_effect_scope() -> None: + current = execution() + slot = paid_slot(current) + altered = effect(current, slot).model_copy(update={"task_id": new_record_id()}) + + with pytest.raises(ValueError, match="recovery effect mismatch"): + checker_model_call_resource( + RECOVER, + ModelCallRecoverAuthorityFacts(execution=current, effect=altered), + ) + + +def test_grant_resource_binds_opaque_digests_and_exact_slot() -> None: + current = execution() + slot = paid_slot(current) + facts = ModelCallGrantAuthorityFacts( + execution=current, + slot=slot, + external_request_digest=sha("external request"), + token_hash=sha("opaque token"), + ) + resource = checker_model_call_resource(GRANT, facts) + + assert resource.scope_project_id == current.project_id + assert resource.resource_id == current.evaluation_request_id + assert "token" not in resource.model_dump(mode="json")["facts"] + assert resource.facts.token_hash == sha("opaque token") + + +def test_output_final_facts_cannot_substitute_selector_or_unverified_binding() -> None: + current = execution() + slot = paid_slot(current) + selected = selector(current, slot) + reservation = CheckerOutputReservation( + selector=selected, + slot=CheckerOutputSlot(key="model_request", maximum_bytes=1024), + ) + request_digest = canonical_json_hash( + { + "model_call_slot_id": str(selected.model_call_slot_id), + "model_call_attempt_id": str(selected.model_call_attempt_id), + "paid_slot": selected.paid_slot.model_dump(mode="json"), + "task_id": str(selected.task_id), + "content_sha256": selected.content_sha256, + "slot_key": "model_request", + "media_type": "application/json", + "maximum_bytes": 1024, + } + ) + stored = SimpleNamespace( + put_attempt_id=new_record_id(), + checker_request_digest=request_digest, + sha256=selected.content_sha256, + byte_count=100, + media_type="application/json", + status="verified", + content_id=new_record_id(), + replica_id=new_record_id(), + verification_receipt_id=new_record_id(), + ) + prepared = checker_output_prepare_resource(BIND, selected) + final = checker_output_stored_resource(BIND, selected, reservation, stored) + + assert checker_output_prepare_matches(prepared, final) + substituted = selected.model_copy(update={"model_call_attempt_id": new_record_id()}) + assert not checker_output_prepare_matches( + prepared, + final.model_copy( + update={ + "resource_id": substituted.model_call_attempt_id, + "selector": substituted, + } + ), + ) + unverified = SimpleNamespace( + **{ + **stored.__dict__, + "status": "object_confirmed", + "content_id": None, + "replica_id": None, + "verification_receipt_id": None, + } + ) + assert checker_output_prepare_matches( + checker_output_prepare_resource(WRITE, selected), + checker_output_stored_resource(WRITE, selected, reservation, unverified), + ) + with pytest.raises(ValidationError, match="binding requires verified ancestry"): + checker_output_stored_resource( + BIND, + selected, + reservation, + unverified, + ) + + +class _Prepared: + def __init__(self, authorization) -> None: + self._authorization = authorization + self._context = authorization._context + self.resource = None + + async def prepare(self, action, caller_input, scope): + self.action, self.input, self.scope = action, caller_input, scope + return object.__new__(PreparedAuthorizationHandle) + + async def consume(self, handle, action, caller_input, resource): + self.resource = resource + return AuthorizationDecision( + decision_id=new_record_id(), + allowed=True, + action_id=action, + permission_id=PermissionId.CHECKER_MODEL_CALL_GRANT, + resource_type=resource.resource_type, + resource_id=resource.resource_id, + resource_context_digest=sha("decision resource"), + matched_authority_kind=MatchedAuthorityKind.FIXED_SERVICE, + matched_grant_id=None, + matched_scope_project_id=None, + request_id=self._context.request_id, + correlation_id=self._context.correlation_id, + revalidated=True, + denial_code=None, + ) + + +class _Authorization: + def __init__(self, context) -> None: + self._context = context + + +@pytest.mark.asyncio +async def test_grant_adapter_emits_exact_resource_and_rejects_wrong_service() -> None: + current = execution() + slot = paid_slot(current) + context = ServiceAuthorizationContext( + actor_profile_id=new_record_id(), + actor_kind=ActorKind.SERVICE, + actor_status=ActorStatus.ACTIVE, + identity_link_id=new_record_id(), + identity_link_status=IdentityLinkStatus.ACTIVE, + service_identity=ServiceIdentity.CHECKER_POST_SUBMIT, + request_id=new_record_id(), + correlation_id=new_record_id(), + ) + authorization = _Authorization(context) + prepared = _Prepared(authorization) + adapter = CheckerModelAuthorization(authorization, prepared) + + evidence = await adapter.authorize_grant( + current, slot, sha("external request"), sha("opaque token") + ) + + assert prepared.action is ActionId.CHECKER_MODEL_CALL_GRANT + assert prepared.resource.facts.execution == current + assert prepared.resource.facts.slot == slot + assert evidence.facts_digest == canonical_json_hash( + { + "action": "grant", + "facts": prepared.resource.facts.model_dump(mode="json"), + } + ) + + wrong_context = context.model_copy( + update={"service_identity": ServiceIdentity.ARTIFACT_CHECKER_OUTPUT} + ) + wrong_authorization = _Authorization(wrong_context) + with pytest.raises(ModelCallUnavailable, match="authority_unavailable"): + await CheckerModelAuthorization( + wrong_authorization, _Prepared(wrong_authorization) + ).authorize_grant(current, slot, sha("external request"), sha("opaque token")) diff --git a/backend/tests/authorization/test_catalogue.py b/backend/tests/authorization/test_catalogue.py index 73c2c8b20..10cf9b309 100644 --- a/backend/tests/authorization/test_catalogue.py +++ b/backend/tests/authorization/test_catalogue.py @@ -26,7 +26,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> assert {item.value for item in HISTORICAL_PERMISSION_IDS} == historical_permissions assert {item.value for item in NEW_PERMISSION_IDS} == new_permissions assert {item.value for item in PERMISSION_IDS} == historical_permissions | new_permissions - assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 177 + assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 186 assert set(ACTION_BY_ID) == ACTION_IDS assert {definition.owner for definition in ACTION_DEFINITIONS} == set(ActionOwner) assert { @@ -105,8 +105,8 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> } assert all(not owner.value.startswith("WS-REV-") for owner in ActionOwner) assert Counter(definition.availability for definition in ACTION_DEFINITIONS) == { - ActionAvailability.ACTIVE: 143, - ActionAvailability.PLANNED: 34, + ActionAvailability.ACTIVE: 154, + ActionAvailability.PLANNED: 32, } assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is PermissionId.ACTOR_PROFILE_READ_SELF with pytest.raises(ValueError, match="not active"): @@ -132,7 +132,7 @@ def test_proposal_actions_have_exact_executable_authority(action): def test_permission_definition_response_is_exact() -> None: permission_response = AdminRoleGrantService.permission_definitions() - assert permission_response.total == 82 + assert permission_response.total == 91 assert [item.permission_id.value for item in permission_response.items] == sorted( permission.value for permission in PermissionId ) @@ -147,7 +147,7 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> identity: {action.value for action in actions} for identity, actions in SERVICE_ACTIONS_BY_IDENTITY.items() } == expected - assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 32 + assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 38 assert FUTURE_INTENT_REQUIRED_ACTIONS == { ActionId.REVIEW_FINDING_EVIDENCE_INGEST, ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, @@ -208,6 +208,12 @@ def test_active_fixed_service_actions_are_exact(): ActionId.CHECKER_PRE_SUBMIT_FINALIZE, ActionId.CHECKER_POST_SUBMIT_EXECUTE, ActionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionId.CHECKER_MODEL_CALL_PREPARE, + ActionId.CHECKER_MODEL_CALL_DISPATCH, + ActionId.CHECKER_MODEL_CALL_RECOVER, + ActionId.CHECKER_MODEL_CALL_OBSERVE, + ActionId.CHECKER_MODEL_CALL_COMPLETE, + ActionId.CHECKER_MODEL_CALL_GRANT, ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, ActionId.TASK_REVISION_EXPIRE, @@ -219,6 +225,8 @@ def test_active_fixed_service_actions_are_exact(): ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, ActionId.ARTIFACT_GUIDE_SOURCE_READ, ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, diff --git a/backend/tests/checker_output_admission_helpers.py b/backend/tests/checker_output_admission_helpers.py index e9376dd50..87dd24ce3 100644 --- a/backend/tests/checker_output_admission_helpers.py +++ b/backend/tests/checker_output_admission_helpers.py @@ -1,11 +1,13 @@ """Controlled complete CHECKERS owner facts for ART output-custody tests.""" +from datetime import UTC, datetime, timedelta from uuid import UUID from sqlalchemy import func, select from sqlalchemy.ext.asyncio import async_sessionmaker from app.core.identifiers import new_record_id +from app.core.hashing import canonical_json_hash from app.modules.actors.api import ServiceIdentity from app.modules.actors.service import ActorService from app.modules.artifacts.schemas import ( @@ -24,10 +26,17 @@ ) from app.modules.checkers.api.output_custody import ( CheckerOutputReservation, + CheckerOutputSelector, CheckerOutputSlot, ) +from app.modules.checkers.api.model_calls import ModelCallExecutionFacts, ModelPaidSlotKey from app.modules.checkers.execution_repository import stored_request -from app.modules.checkers.models import CheckerRun +from app.modules.checkers.model_calls.models import ( + CheckerModelCallAttempt, + CheckerModelCallSlot, + CheckerModelProfile, +) +from app.modules.checkers.models import CheckerRun, ExternalCheckerRegistryEntryRecord from app.modules.tasks.models import Submission, WorkstreamTask @@ -35,13 +44,12 @@ async def make_checker_output_reservation( session, checker_run_id: str | UUID, *, - slot_key: str = "platform-review", - media_type: str = "application/octet-stream", + slot_key: str = "model_response", + media_type: str = "application/json", maximum_bytes: int = 1024, - worker_lease_id: UUID | None = None, - worker_lease_generation: int = 1, + content_sha256: str | None = None, ) -> CheckerOutputReservation: - """Derive hash-valid public owner facts from one actual seeded run.""" + """Create one call-stable model effect beneath an actual seeded run.""" checker_run = await session.get(CheckerRun, str(checker_run_id)) assert checker_run is not None submission = await session.scalar( @@ -54,22 +62,140 @@ async def make_checker_output_reservation( task = await session.get(WorkstreamTask, checker_run.task_id) assert submission is not None and task is not None evaluation = stored_request(checker_run) - assert (evaluation.project_id, evaluation.task_id, evaluation.submission_id, - evaluation.submission_version, evaluation.assignment_id) == ( - UUID(task.project_id), UUID(task.id), UUID(submission.id), - submission.version, UUID(submission.task_assignment_id), + assert ( + evaluation.project_id, + evaluation.task_id, + evaluation.submission_id, + evaluation.submission_version, + evaluation.assignment_id, + ) == ( + UUID(task.project_id), + UUID(task.id), + UUID(submission.id), + submission.version, + UUID(submission.task_assignment_id), + ) + registry = await session.scalar(select(ExternalCheckerRegistryEntryRecord).limit(1)) + assert registry is not None + profile_version = ( + int( + await session.scalar( + select(func.coalesce(func.max(CheckerModelProfile.profile_version), 0)).where( + CheckerModelProfile.project_id == str(evaluation.project_id) + ) ) - return CheckerOutputReservation( - evaluation=evaluation, + or 0 + ) + + 1 + ) + profile_id = new_record_id() + profile_digest = canonical_json_hash( + {"project_id": str(evaluation.project_id), "profile_id": str(profile_id)} + ) + profile = CheckerModelProfile( + id=str(profile_id), + project_id=str(evaluation.project_id), + profile_version=profile_version, + profile_digest=profile_digest, + registry_entry_id=str(registry.id), + registry_entry_digest=registry.entry_digest, + provider="test_provider", + api="test.responses.v1", + model_snapshot="test-model-2026-10-01", + generation_settings={"reasoning_effort": "low", "text_verbosity": "low"}, + maximum_request_bytes=maximum_bytes, + maximum_output_bytes=maximum_bytes, + maximum_response_bytes=max(maximum_bytes, 6 * maximum_bytes + 4096), + maximum_input_tokens=1024, + maximum_output_tokens=1024, + deadline_ms=30_000, + call_slots=1, + pricing_policy_id=str(new_record_id()), + pricing_policy_version=1, + pricing_policy_digest=canonical_json_hash({"profile_id": str(profile_id), "rate": 1}), + uncached_input_rate=1, + cached_input_rate=1, + output_rate=1, + operation_id=str(new_record_id()), + actor_profile_id=str(new_record_id()), + authorization_evidence_id=str(new_record_id()), + ) + session.add(profile) + configuration_sha256 = canonical_json_hash({"profile_id": str(profile_id)}) + paid_slot = ModelPaidSlotKey( + project_id=evaluation.project_id, + assignment_id=evaluation.assignment_id, + submission_id=evaluation.submission_id, + submission_version=evaluation.submission_version, + requirement_id="model_evidence", + registry_entry_digest=registry.entry_digest, + configuration_sha256=configuration_sha256, + profile_id=profile_id, + profile_version=profile_version, + profile_digest=profile_digest, + ) + digest = content_sha256 or canonical_json_hash( + {"model_call": str(profile_id), "role": slot_key} + ) + model_slot_id, attempt_id = new_record_id(), new_record_id() + session.add( + CheckerModelCallSlot( + id=str(model_slot_id), + project_id=str(evaluation.project_id), + task_id=str(evaluation.task_id), + assignment_id=str(evaluation.assignment_id), + submission_id=str(evaluation.submission_id), + submission_version=evaluation.submission_version, + requirement_id=paid_slot.requirement_id, + registry_entry_digest=paid_slot.registry_entry_digest, + configuration_sha256=paid_slot.configuration_sha256, + profile_id=str(profile_id), + profile_version=profile_version, + profile_digest=profile_digest, + slot_index=0, + first_request_sha256=digest, + ) + ) + session.add( + CheckerModelCallAttempt( + id=str(attempt_id), + slot_id=str(model_slot_id), + attempt_number=1, + internal_call_request_id=str(new_record_id()), + request_sha256=digest, + state="dispatching", + prepared_authorization_evidence_id=str(new_record_id()), + ) + ) + await session.flush() + execution = ModelCallExecutionFacts( + project_id=evaluation.project_id, + task_id=evaluation.task_id, + assignment_id=evaluation.assignment_id, + submission_id=evaluation.submission_id, + submission_version=evaluation.submission_version, + evaluation_request_id=evaluation.evaluation_request_id, + evaluation_generation=evaluation.evaluation_generation, checker_run_id=UUID(checker_run.id), - worker_lease_id=worker_lease_id or new_record_id(), - worker_lease_generation=worker_lease_generation, - slots=( - CheckerOutputSlot( - key=slot_key, - media_type=media_type, - maximum_bytes=maximum_bytes, - ), + worker_lease_id=new_record_id(), + worker_lease_generation=1, + worker_lease_expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + selector = CheckerOutputSelector( + paid_slot=paid_slot, + task_id=evaluation.task_id, + model_call_slot_id=model_slot_id, + model_call_attempt_id=attempt_id, + slot_key=slot_key, + content_sha256=digest, + execution=execution if slot_key == "model_request" else None, + ) + return CheckerOutputReservation( + selector=selector, + slot=CheckerOutputSlot( + key=slot_key, + media_type=media_type, + maximum_bytes=maximum_bytes, ), ) @@ -79,9 +205,7 @@ async def make_checker_output_admission_request( checker_run_id: str, source, *, - slot_key: str = "platform-review", - worker_lease_id: UUID | None = None, - worker_lease_generation: int = 1, + slot_key: str = "model_response", ) -> CheckerOutputArtifactAdmissionRequest: """Build detached owner facts without rolling back or expiring caller state.""" factory = async_sessionmaker(session.bind, expire_on_commit=False) @@ -92,9 +216,9 @@ async def make_checker_output_admission_request( slot_key=slot_key, media_type=source.commitment.media_type, maximum_bytes=max(1, source.commitment.byte_count), - worker_lease_id=worker_lease_id, - worker_lease_generation=worker_lease_generation, + content_sha256=source.commitment.sha256, ) + await reservation_session.commit() return CheckerOutputArtifactAdmissionRequest( reservation=reservation, slot_key=slot_key, @@ -137,7 +261,6 @@ async def assert_exact_checker_admission( *, result, replay, - takeover, request, authority, project_id: str, @@ -147,35 +270,46 @@ async def assert_exact_checker_admission( ) -> None: """Retain the complete fixed-service, quota and replay assertion set.""" attempt = await session.get(ArtifactPutAttempt, str(result.attempt_id)) - scopes = (await session.scalars(select(ArtifactAdmissionScope).order_by( - ArtifactAdmissionScope.scope_type, ArtifactAdmissionScope.scope_id, - ))).all() - links = (await session.scalars(select(ArtifactPutAttemptCharge).where( - ArtifactPutAttemptCharge.attempt_id == str(result.attempt_id), - ))).all() + scopes = ( + await session.scalars( + select(ArtifactAdmissionScope).order_by( + ArtifactAdmissionScope.scope_type, + ArtifactAdmissionScope.scope_id, + ) + ) + ).all() + links = ( + await session.scalars( + select(ArtifactPutAttemptCharge).where( + ArtifactPutAttemptCharge.attempt_id == str(result.attempt_id), + ) + ) + ).all() assert attempt is not None assert replay.attempt_id == result.attempt_id assert replay.charge_ids == result.charge_ids - assert takeover.attempt_id == result.attempt_id - assert takeover.replayed is True assert attempt.status == "prepared" assert attempt.producer_request_type == "checker_output" assert attempt.producer_type == "service_identity" assert attempt.producer_ref == ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value assert attempt.project_id == project_id assert attempt.task_id == task_id - assert attempt.checker_run_id == checker_run_id - assert attempt.logical_role == "platform-review" - assert attempt.submission_id == str(request.reservation.evaluation.submission_id) - assert attempt.submission_version == request.reservation.evaluation.submission_version + assert attempt.checker_run_id is None + assert attempt.model_call_attempt_id == str(request.reservation.selector.model_call_attempt_id) + assert attempt.logical_role == request.reservation.selector.slot_key + assert attempt.submission_id == str(request.reservation.selector.paid_slot.submission_id) + assert attempt.submission_version == request.reservation.selector.paid_slot.submission_version assert attempt.checker_request_digest is not None - assert authority.calls == 3 + assert authority.calls == 2 assert attempt.executor_id is None assert attempt.lease_expires_at is None assert attempt.next_run_at is None assert attempt.execution_generation == 0 assert {scope.scope_type for scope in scopes} == { - "deployment", "producer", "project", "task", + "deployment", + "producer", + "project", + "task", } assert len(result.charge_ids) == 4 assert len(links) == 4 @@ -211,15 +345,14 @@ async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: or proof.identity_link_id != str(self._identity_link_id) or proof.identity_link_subject_kind != "service" or proof.identity_link_status != "active" - or proof.service_identity - != ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value + or proof.service_identity != ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value ): - raise ArtifactAuthorityDeniedError( - "checker output service identity is unavailable" - ) + raise ArtifactAuthorityDeniedError("checker output service identity is unavailable") -async def seed_checker_output_relationships(session, namespace, *, policy_bundle=None) -> tuple[str, str, str]: +async def seed_checker_output_relationships( + session, namespace, *, policy_bundle=None +) -> tuple[str, str, str]: """Prepare canonical input before callers mint bounded output scratch.""" from tests.test_artifact_admission import _seed_human_actor, _context from tests.projects.unified_policy_fixtures import create_standalone_unified_policy @@ -227,7 +360,8 @@ async def seed_checker_output_relationships(session, namespace, *, policy_bundle if policy_bundle is None: assert not session.in_transaction(), "Arrange canonical setup before staging artifact rows" policy_bundle = await create_standalone_unified_policy( - async_sessionmaker(session.bind, expire_on_commit=False), namespace, + async_sessionmaker(session.bind, expire_on_commit=False), + namespace, ) values, effective, _pre = policy_bundle project_id = str(values["project"]) @@ -247,8 +381,12 @@ async def seed_checker_output_relationships(session, namespace, *, policy_bundle from tests.test_tasks import complete_submission_payload assignment_id = str(new_record_id()) - params = {"task": task_id, "assignment": assignment_id, "project": project_id, - "actor": contributor_id} + params = { + "task": task_id, + "assignment": assignment_id, + "project": project_id, + "actor": contributor_id, + } connection = await session.connection() await seed_started_task_for_artifact_test(connection, params) await install_submitter_grant(connection, params) @@ -259,6 +397,8 @@ async def seed_checker_output_relationships(session, namespace, *, policy_bundle ) submission_id = await seed_retained_submission(task_id, payload) from tests.checkers.execution.storage_fixture import seed_storage_run - checker_run_id = await seed_storage_run(async_sessionmaker(session.bind, expire_on_commit=False), - submission_id, state="queued") + + checker_run_id = await seed_storage_run( + async_sessionmaker(session.bind, expire_on_commit=False), submission_id, state="queued" + ) return project_id, task_id, checker_run_id diff --git a/backend/tests/checker_output_custody_helpers.py b/backend/tests/checker_output_custody_helpers.py index ed6d20948..1d5277d03 100644 --- a/backend/tests/checker_output_custody_helpers.py +++ b/backend/tests/checker_output_custody_helpers.py @@ -5,6 +5,7 @@ from tests.checker_output_admission_helpers import seed_checker_output_relationships import asyncio +import hashlib from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass, field @@ -47,15 +48,16 @@ CheckerOutputSelector, CheckerOutputUnavailable, ) -from app.modules.checkers.models import CheckerRun +from app.modules.authorization.checker_output_authorization import ( + PreparedCheckerOutputBindingAuthorization, + PreparedCheckerOutputWriteAuthorization, +) +from app.modules.checkers.model_calls.models import CheckerModelCallAttempt from tests.artifact_store_helpers import ( artifact_admission_limit_settings, artifact_preparation_limits, ) -from tests.checker_output_admission_helpers import ( - ControlledCheckerOutputAdmissionAuthority, - make_checker_output_reservation, -) +from tests.checker_output_admission_helpers import make_checker_output_reservation from tests.projects.unified_policy_fixtures import create_standalone_unified_policy from tests.test_artifact_admission import ( _AllowArtifactAuthority, @@ -65,15 +67,8 @@ def selector_for(reservation: CheckerOutputReservation) -> CheckerOutputSelector: - """Select the sole controlled output slot under the reservation's current lease.""" - assert len(reservation.slots) == 1 - return CheckerOutputSelector( - evaluation=reservation.evaluation, - checker_run_id=reservation.checker_run_id, - worker_lease_id=reservation.worker_lease_id, - worker_lease_generation=reservation.worker_lease_generation, - slot_key=reservation.slots[0].key, - ) + """Select the exact call-stable effect role issued by CHECKERS.""" + return reservation.selector async def byte_stream(*chunks: bytes, observed: list[bytes] | None = None) -> AsyncIterator[bytes]: @@ -108,55 +103,55 @@ def require_authority(self) -> None: class ControlledReservationPort: - """Lock a real run, then return controlled detached CHECKERS owner facts.""" + """Lock a real call effect, then return controlled detached owner facts.""" def __init__(self, session: AsyncSession, state: ControlledReservationState) -> None: self._session = session self._state = state async def resolve(self, selector: CheckerOutputSelector) -> CheckerOutputReservation: - """Fence the selected real run before ART acquires any artifact row lock.""" - checker_run = await self._session.scalar( - select(CheckerRun) - .where(CheckerRun.id == str(selector.checker_run_id)) + """Fence the selected call attempt before ART acquires any artifact row lock.""" + attempt = await self._session.scalar( + select(CheckerModelCallAttempt) + .where( + CheckerModelCallAttempt.id == str(selector.model_call_attempt_id), + CheckerModelCallAttempt.slot_id == str(selector.model_call_slot_id), + ) .with_for_update() .execution_options(populate_existing=True) ) - if checker_run is None: + if attempt is None: raise CheckerOutputUnavailable("checker_output_reservation_unavailable") self._state.resolved.append(selector) + self._state.reservation.select(selector) return self._state.reservation class ControlledWriteAuthority: - """Test-only exact action authority plus real fixed-service admission proof.""" + """Record test controls around production prepared fixed-service authority.""" def __init__( self, session: AsyncSession, state: ControlledReservationState, - actor_id: UUID, - identity_link_id: UUID, ) -> None: self._state = state - self._admission = ControlledCheckerOutputAdmissionAuthority( - session, - actor_id, - identity_link_id, - ) + self._authority = PreparedCheckerOutputWriteAuthorization(session) async def preflight(self, selector: CheckerOutputSelector) -> None: self._state.require_authority() self._state.preflights.append(selector) + await self._authority.preflight(selector) async def prepare(self, selector: CheckerOutputSelector) -> None: self._state.require_authority() self._state.prepares.append(selector) + await self._authority.prepare(selector) async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: self._state.require_authority() assert request.reservation == self._state.reservation - await self._admission.consume(request) + await self._authority.consume(request) self._state.admissions.append(request) async def consume_existing( @@ -166,25 +161,30 @@ async def consume_existing( ) -> None: self._state.require_authority() assert reservation == self._state.reservation + await self._authority.consume_existing(reservation, facts) self._state.recoveries.append((reservation, facts)) def discard(self) -> None: - """The controlled seam retains no prepared capability.""" + """Dispose the production prepared capability.""" + self._authority.discard() class ControlledBindingAuthority: - """Test-only binding authority that records exact verified custody.""" + """Record test controls around production binding authority.""" - def __init__(self, state: ControlledReservationState) -> None: + def __init__(self, session: AsyncSession, state: ControlledReservationState) -> None: self._state = state + self._authority = PreparedCheckerOutputBindingAuthorization(session) async def preflight(self, selector: CheckerOutputSelector) -> None: self._state.require_authority() self._state.preflights.append(selector) + await self._authority.preflight(selector) async def prepare(self, selector: CheckerOutputSelector) -> None: self._state.require_authority() self._state.prepares.append(selector) + await self._authority.prepare(selector) async def consume( self, @@ -193,10 +193,12 @@ async def consume( ) -> None: self._state.require_authority() assert reservation == self._state.reservation + await self._authority.consume(reservation, facts) self._state.bindings.append((reservation, facts)) def discard(self) -> None: - """The controlled seam retains no prepared capability.""" + """Dispose the production prepared capability.""" + self._authority.discard() class ObservedStore: @@ -266,6 +268,16 @@ def request( selector: CheckerOutputSelector | None = None, observed: list[bytes] | None = None, ) -> CheckerOutputArtifactRequest: + if selector is None and not self.state.preflights: + content_sha256 = "sha256:" + hashlib.sha256(b"".join(chunks)).hexdigest() + current = self.state.reservation + self.state.reservation = current.model_copy( + update={ + "selector": current.selector.model_copy( + update={"content_sha256": content_sha256} + ) + } + ) return CheckerOutputArtifactRequest( selector=selector or self.selector, byte_source=byte_stream(*chunks, observed=observed), @@ -275,7 +287,7 @@ def binding_service(self, session: AsyncSession) -> CheckerOutputBindingService: return CheckerOutputBindingService( session, reservations=ControlledReservationPort(session, self.state), - authority=ControlledBindingAuthority(self.state), + authority=ControlledBindingAuthority(session, self.state), namespace_fingerprint=self.namespace.namespace_fingerprint, ) @@ -306,7 +318,9 @@ async def binding_rows(self) -> list[tuple[str, str, str, str, str]]: ArtifactBinding.resource_id, ArtifactBinding.put_attempt_id, ArtifactBinding.verification_receipt_id, - ).where(ArtifactBinding.resource_type == "checker_run").order_by(ArtifactBinding.id) + ) + .where(ArtifactBinding.resource_type == "checker_model_call") + .order_by(ArtifactBinding.id) ) return [tuple(row) for row in rows] @@ -314,6 +328,7 @@ async def seed_reservation( self, *, maximum_bytes: int = 1024, + content_sha256: str | None = None, ) -> CheckerOutputReservation: """Create a second coherent lineage without changing the active test reservation.""" async with self.factory() as session: @@ -326,8 +341,9 @@ async def seed_reservation( session, checker_run_id, maximum_bytes=maximum_bytes, + content_sha256=content_sha256, ) - await session.rollback() + await session.commit() return reservation @@ -404,18 +420,14 @@ async def output_custody_harness( checker_run_id, maximum_bytes=maximum_bytes, ) - await session.rollback() + await session.commit() state = ControlledReservationState(reservation) service = CheckerArtifactOutputService( sessions=factory, reservations=lambda session: ControlledReservationPort(session, state), - authority=lambda session: ControlledWriteAuthority( - session, - state, - actor_id, - identity_link_id, - ), + authority=lambda session: ControlledWriteAuthority(session, state), internal_authority=lambda session: _AllowArtifactAuthority(), + verification_authority=lambda session: _AllowArtifactAuthority(), preparation=preparation, store=store, namespace=namespace, diff --git a/backend/tests/checkers/execution/test_execution.py b/backend/tests/checkers/execution/test_execution.py index 5333faa02..6da274324 100644 --- a/backend/tests/checkers/execution/test_execution.py +++ b/backend/tests/checkers/execution/test_execution.py @@ -193,30 +193,14 @@ async def prepare_finalization(self, request): async def test_zero_output_finalization(tmp_path, isolated_database_env): from pydantic import ValidationError from app.core.identifiers import new_record_id - from app.modules.checkers.api.output_custody import ( - CheckerOutputSelector, - CheckerOutputUnavailable, - ) - from app.modules.checkers.execution_coordination import CheckerOutputReservations from .test_concurrency import final_facts async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) executor = live_executor(h) lease, _ = await executor._claim(h.request) - selector = CheckerOutputSelector( - evaluation=h.request, - checker_run_id=lease.reservation.attempt_id, - worker_lease_id=lease.lease_id, - worker_lease_generation=lease.lease_generation, - slot_key="invented", - ) - async with h.factory() as session, session.begin(): - slots = await CheckerOutputReservations(session).resolve(selector) - assert slots.slots == () - with pytest.raises(CheckerOutputUnavailable, match="slot_unavailable"): - slots.select(selector) facts = await final_facts(h, lease) + assert facts.output_binding_ids == () with pytest.raises(ValidationError): await executor.finalize( facts.model_copy(update={"output_binding_ids": (new_record_id(),)}) diff --git a/backend/tests/checkers/external/test_harnessy_proof_checker.py b/backend/tests/checkers/external/test_harnessy_proof_checker.py index 349db03b8..d278b837a 100644 --- a/backend/tests/checkers/external/test_harnessy_proof_checker.py +++ b/backend/tests/checkers/external/test_harnessy_proof_checker.py @@ -169,6 +169,8 @@ def test_published_service_paths_match_the_compose_overlay() -> None: assert config["material_root"] == environment["WORKSTREAM_EXTERNAL_CHECKER_MATERIAL_ROOT"] assert config["material_root"] == environment["WORKSTREAM_ARTIFACT_SCRATCH_ROOT"] assert config["socket_path"] == environment["WORKSTREAM_EXTERNAL_CHECKER_SERVICE_SOCKET"] + assert "model_proxy_socket" not in config + assert "model_relay_root" not in config assert len(config["socket_path"].encode()) <= 107 for name in ("backend", "worker", "beat", "checker", "checker-volumes-init"): socket_mounts = [ @@ -179,3 +181,45 @@ def test_published_service_paths_match_the_compose_overlay() -> None: ] mode = ":ro" if name in ("backend", "worker", "beat") else "" assert socket_mounts == [f"checker_socket:/run/ws-checker{mode}"], name + + model_config = publish_local.service_config( + {"operating_system": "linux", "architecture": "arm64"}, + compose_project="${COMPOSE_PROJECT_NAME}", + sandbox_uid=501, + sandbox_gid=20, + enable_model_proxy=True, + ) + relay_root = "/var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-relay/_data" + assert model_config["model_proxy_socket"] == "/run/ws-model-proxy/model-proxy.sock" + assert model_config["model_relay_root"] == relay_root + assert len(f"{relay_root}/.model-{'0' * 16}/model.sock".encode()) < 108 + assert "checker_model_proxy:/run/ws-model-proxy" in overlay["services"]["backend"]["volumes"] + for name in ("backend", "worker", "beat"): + assert all( + "/var/run/docker.sock" not in str(mount) + for mount in overlay["services"][name]["volumes"] + ) + for name in ("worker", "beat"): + assert all( + "checker_model_proxy" not in str(mount) + for mount in overlay["services"][name]["volumes"] + ) + assert "checker_model_proxy:/run/ws-model-proxy:ro" in overlay["services"]["checker"]["volumes"] + assert ( + "checker_model_relay:" + "/var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-relay/_data" + in overlay["services"]["checker"]["volumes"] + ) + for volume in ("checker_model_proxy", "checker_model_relay"): + assert any( + isinstance(mount, str) and mount.startswith(f"{volume}:") + for mount in overlay["services"]["checker-volumes-init"]["volumes"] + ) + with pytest.raises(ValueError, match="relay socket path too long"): + publish_local.service_config( + {"operating_system": "linux", "architecture": "arm64"}, + compose_project="x" * 80, + sandbox_uid=501, + sandbox_gid=20, + enable_model_proxy=True, + ) diff --git a/backend/tests/checkers/external/test_model_proxy_transport.py b/backend/tests/checkers/external/test_model_proxy_transport.py new file mode 100644 index 000000000..2fb51f114 --- /dev/null +++ b/backend/tests/checkers/external/test_model_proxy_transport.py @@ -0,0 +1,356 @@ +"""The private model proxy transport is bounded and retains dispatched work.""" + +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +import json +import os +from pathlib import Path +import socket +import stat +import struct + +import pytest + +from app.adapters.checkers.model_proxy_transport import ( + ModelProxyCleanupError, + ModelProxyCustodyError, + ModelProxyTransport, +) +from app.interfaces.checker_model_proxy import ( + MODEL_PROXY_MAXIMUM_FRAME_BYTES, + MODEL_PROXY_PROTOCOL, + ModelProxyReply, + ModelProxyRequest, +) + + +GRANT_ID = "model_" + "a" * 32 +REQUEST_DIGEST = "sha256:" + "b" * 64 +RECEIPT_DIGEST = "sha256:" + "c" * 64 + + +def request_value(*, prompt: str = "Judge this bounded artifact.") -> dict[str, str]: + """Build the exact Rust relay request root.""" + return { + "protocol_version": MODEL_PROXY_PROTOCOL, + "grant_id": GRANT_ID, + "request_digest": REQUEST_DIGEST, + "prompt": prompt, + } + + +def frame(value: object) -> bytes: + """Encode one private protocol frame.""" + body = json.dumps(value, separators=(",", ":")).encode() + return struct.pack(">I", len(body)) + body + + +async def read_frame(reader: asyncio.StreamReader) -> object: + """Read one private protocol frame.""" + size = struct.unpack(">I", await reader.readexactly(4))[0] + return json.loads(await reader.readexactly(size)) + + +@asynccontextmanager +async def connection( + path: Path, +) -> AsyncIterator[tuple[asyncio.StreamReader, asyncio.StreamWriter]]: + """Open and close one real Unix connection.""" + reader, writer = await asyncio.open_unix_connection(path) + try: + yield reader, writer + finally: + writer.close() + try: + await asyncio.wait_for(writer.wait_closed(), 1) + except (TimeoutError, OSError): + pass + + +def private_socket_path(tmp_path: Path) -> Path: + """Create a canonical same-identity private listener parent.""" + parent = tmp_path / "private" + parent.mkdir(mode=0o700) + return parent / "proxy.sock" + + +@pytest.mark.asyncio +async def test_real_socket_round_trip_has_exact_contract_and_private_custody(tmp_path): + path = private_socket_path(tmp_path) + received: list[ModelProxyRequest] = [] + + async def handle(request: ModelProxyRequest) -> ModelProxyReply: + received.append(request) + return ModelProxyReply( + outcome="completed", + output="bounded result", + receipt_digest=RECEIPT_DIGEST, + failure_code=None, + ) + + transport = ModelProxyTransport(socket_path=path, handler=handle) + async with transport: + metadata = os.lstat(path) + assert stat.S_ISSOCK(metadata.st_mode) + assert stat.S_IMODE(metadata.st_mode) == 0o600 + assert (metadata.st_uid, metadata.st_gid) == (os.geteuid(), os.getegid()) + async with connection(path) as (reader, writer): + writer.write(frame(request_value())) + await writer.drain() + assert await read_frame(reader) == { + "protocol_version": MODEL_PROXY_PROTOCOL, + "outcome": "completed", + "output": "bounded result", + "receipt_digest": RECEIPT_DIGEST, + "failure_code": None, + } + + assert received == [ + ModelProxyRequest( + grant_id=GRANT_ID, + request_digest=REQUEST_DIGEST, + prompt="Judge this bounded artifact.", + ) + ] + assert GRANT_ID not in repr(received[0]) + assert received[0].prompt not in repr(received[0]) + assert "bounded result" not in repr( + ModelProxyReply("completed", "bounded result", RECEIPT_DIGEST, None) + ) + assert not path.exists() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + [ + b"not-json", + json.dumps({**request_value(), "provider": "secret"}).encode(), + json.dumps( + {key: value for key, value in request_value().items() if key != "prompt"} + ).encode(), + ( + b'{"protocol_version":"workstream.checker_model_proxy.v1",' + b'"grant_id":"model_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",' + b'"request_digest":"sha256:' + b"b" * 64 + b'","prompt":"first","prompt":"second"}' + ), + json.dumps({**request_value(), "grant_id": "model_" + "A" * 32}).encode(), + json.dumps({**request_value(), "protocol_version": "model_proxy.v0"}).encode(), + json.dumps({**request_value(), "prompt": None}).encode(), + ], +) +async def test_malformed_or_open_request_is_closed_without_dispatch(tmp_path, payload): + path = private_socket_path(tmp_path) + calls = 0 + + async def handle(_: ModelProxyRequest) -> ModelProxyReply: + nonlocal calls + calls += 1 + return ModelProxyReply("unavailable", None, None, "provider_unavailable") + + async with ModelProxyTransport(socket_path=path, handler=handle): + async with connection(path) as (reader, writer): + writer.write(struct.pack(">I", len(payload)) + payload) + await writer.drain() + assert await asyncio.wait_for(reader.read(), 1) == b"" + + assert calls == 0 + + +@pytest.mark.asyncio +async def test_oversize_and_incomplete_frames_close_within_read_deadline(tmp_path): + path = private_socket_path(tmp_path) + + async def handle(_: ModelProxyRequest) -> ModelProxyReply: + raise AssertionError("invalid frame reached the handler") + + async with ModelProxyTransport( + socket_path=path, + handler=handle, + read_timeout_seconds=0.05, + ): + async with connection(path) as (reader, writer): + writer.write(struct.pack(">I", MODEL_PROXY_MAXIMUM_FRAME_BYTES + 1)) + await writer.drain() + assert await asyncio.wait_for(reader.read(), 1) == b"" + async with connection(path) as (reader, writer): + writer.write(struct.pack(">I", 10) + b"ab") + await writer.drain() + assert await asyncio.wait_for(reader.read(), 1) == b"" + + +@pytest.mark.asyncio +async def test_oversize_handler_reply_is_closed_without_writing_a_frame(tmp_path): + path = private_socket_path(tmp_path) + + async def handle(_: ModelProxyRequest) -> ModelProxyReply: + return ModelProxyReply( + "completed", + "x" * MODEL_PROXY_MAXIMUM_FRAME_BYTES, + RECEIPT_DIGEST, + None, + ) + + async with ModelProxyTransport(socket_path=path, handler=handle): + async with connection(path) as (reader, writer): + writer.write(frame(request_value())) + await writer.drain() + assert await asyncio.wait_for(reader.read(), 1) == b"" + + +@pytest.mark.asyncio +async def test_connection_bound_rejects_another_dispatch(tmp_path): + path = private_socket_path(tmp_path) + entered = asyncio.Event() + release = asyncio.Event() + calls = 0 + + async def handle(_: ModelProxyRequest) -> ModelProxyReply: + nonlocal calls + calls += 1 + entered.set() + await release.wait() + return ModelProxyReply("unavailable", None, None, "budget_unavailable") + + async with ModelProxyTransport( + socket_path=path, + handler=handle, + maximum_connections=1, + ): + async with connection(path) as (first_reader, first_writer): + first_writer.write(frame(request_value(prompt="first"))) + await first_writer.drain() + await asyncio.wait_for(entered.wait(), 1) + async with connection(path) as (_, second_writer): + second_writer.write(frame(request_value(prompt="second"))) + await second_writer.drain() + await asyncio.sleep(0.05) + assert calls == 1 + release.set() + assert await read_frame(first_reader) == { + "protocol_version": MODEL_PROXY_PROTOCOL, + "outcome": "unavailable", + "output": None, + "receipt_digest": None, + "failure_code": "budget_unavailable", + } + + assert calls == 1 + + +@pytest.mark.asyncio +async def test_client_disconnect_does_not_cancel_or_reissue_dispatched_handler(tmp_path): + path = private_socket_path(tmp_path) + entered = asyncio.Event() + release = asyncio.Event() + completed = asyncio.Event() + cancelled = False + calls = 0 + + async def handle(_: ModelProxyRequest) -> ModelProxyReply: + nonlocal calls, cancelled + calls += 1 + entered.set() + try: + await release.wait() + except asyncio.CancelledError: + cancelled = True + raise + completed.set() + return ModelProxyReply("unavailable", None, None, "provider_unavailable") + + transport = ModelProxyTransport( + socket_path=path, + handler=handle, + shutdown_timeout_seconds=0.05, + ) + await transport.start() + _, writer = await asyncio.open_unix_connection(path) + writer.write(frame(request_value())) + await writer.drain() + await asyncio.wait_for(entered.wait(), 1) + writer.close() + await writer.wait_closed() + + with pytest.raises(TimeoutError, match="handler drain"): + await transport.close() + assert path.exists() + assert calls == 1 + assert not cancelled + release.set() + await asyncio.wait_for(completed.wait(), 1) + await transport.close() + assert calls == 1 + assert not cancelled + assert not path.exists() + + +@pytest.mark.asyncio +async def test_start_refuses_nonprivate_symlink_and_occupied_paths(tmp_path): + public_parent = tmp_path / "public" + public_parent.mkdir(mode=0o755) + + async def handle(_: ModelProxyRequest) -> ModelProxyReply: + return ModelProxyReply("unavailable", None, None, "provider_unavailable") + + with pytest.raises(ModelProxyCustodyError, match="parent custody"): + await ModelProxyTransport(socket_path=public_parent / "proxy.sock", handler=handle).start() + + private_parent = tmp_path / "private" + private_parent.mkdir(mode=0o700) + linked_parent = tmp_path / "linked" + linked_parent.symlink_to(private_parent, target_is_directory=True) + with pytest.raises(ModelProxyCustodyError, match="parent custody"): + await ModelProxyTransport(socket_path=linked_parent / "proxy.sock", handler=handle).start() + + occupied = private_parent / "occupied.sock" + occupied.write_text("not a socket") + with pytest.raises(ModelProxyCustodyError, match="occupied"): + await ModelProxyTransport(socket_path=occupied, handler=handle).start() + assert occupied.read_text() == "not a socket" + + stale = private_parent / "stale.sock" + stale_socket = socket.socket(socket.AF_UNIX) + stale_socket.bind(str(stale)) + stale_socket.close() + with pytest.raises(ModelProxyCustodyError, match="occupied"): + await ModelProxyTransport(socket_path=stale, handler=handle).start() + assert stat.S_ISSOCK(os.lstat(stale).st_mode) + + +@pytest.mark.asyncio +async def test_cleanup_refuses_replaced_socket_and_leaves_it_in_place(tmp_path): + path = private_socket_path(tmp_path) + + async def handle(_: ModelProxyRequest) -> ModelProxyReply: + return ModelProxyReply("unavailable", None, None, "provider_unavailable") + + transport = ModelProxyTransport(socket_path=path, handler=handle) + await transport.start() + path.unlink() + replacement = socket.socket(socket.AF_UNIX) + replacement.bind(str(path)) + os.chmod(path, 0o600) + try: + with pytest.raises(ModelProxyCleanupError, match="identity changed"): + await transport.close() + assert stat.S_ISSOCK(os.lstat(path).st_mode) + finally: + replacement.close() + path.unlink() + + +@pytest.mark.parametrize( + "reply", + [ + ("completed", "", RECEIPT_DIGEST, None), + ("completed", "output", None, None), + ("unavailable", "output", None, "provider_unavailable"), + ("unavailable", None, None, "Provider-Unavailable"), + ("unavailable", None, None, "a" * 65), + ], +) +def test_reply_rejects_invalid_closed_outcomes(reply): + with pytest.raises(ValueError, match="reply is invalid"): + ModelProxyReply(*reply) diff --git a/backend/tests/checkers/external/test_model_transport.py b/backend/tests/checkers/external/test_model_transport.py new file mode 100644 index 000000000..73d3d5c38 --- /dev/null +++ b/backend/tests/checkers/external/test_model_transport.py @@ -0,0 +1,113 @@ +"""Model relay selectors remain private, POST-only and request-bound.""" + +import asyncio +import json +import struct +from unittest.mock import AsyncMock + +import pytest + +from app.adapters.checkers import external_checker_execution_factory +from app.interfaces.external_checker_execution import ( + ExternalCheckerMaterialGrant, + ExternalCheckerModelGrant, +) +from app.interfaces.external_services import ExternalServiceProtocolError +from app.modules.checkers.api.external import ( + external_checker_json_hash, + make_external_checker_execution_request, + make_external_checker_execution_result, +) +from tests.checkers.external.support import ( + execution_identity, + execution_material, + registry_entry, +) + + +def request(phase="post_submit"): + """Build actual normalized custody without assigning model authority.""" + identity = execution_identity(phase) + inputs = {"task_version": "v3"} + return make_external_checker_execution_request( + registry=registry_entry(phase), + identity=identity, + configuration={}, + configuration_sha256=external_checker_json_hash({}), + input=inputs, + input_sha256=external_checker_json_hash(inputs), + materials=(execution_material(identity),), + ) + + +@pytest.mark.asyncio +async def test_model_selector_is_outside_image_request(tmp_path): + value = request() + grant = ExternalCheckerModelGrant("model_" + "a" * 32, value.request_digest) + result = make_external_checker_execution_result( + request_digest=value.request_digest, + registry_entry_id=value.registry.registry_entry_id, + registry_entry_digest=value.registry.entry_digest, + phase="post_submit", + outcome="infrastructure_failed", + verdict=None, + findings=(), + infrastructure_failure_code="implementation_unavailable", + ) + received = asyncio.get_running_loop().create_future() + + async def handle(reader, writer): + try: + size = struct.unpack(">I", await reader.readexactly(4))[0] + received.set_result(json.loads(await reader.readexactly(size))) + body = json.dumps({ + "protocol_version": "external_checker_service.v1", + "result": result.model_dump(mode="json"), + "isolation": None, + }).encode() + writer.write(struct.pack(">I", len(body)) + body) + await writer.drain() + finally: + writer.close() + await writer.wait_closed() + + path = tmp_path / "checker.sock" + server = await asyncio.start_unix_server(handle, path) + try: + adapter = external_checker_execution_factory( + socket_path=path, timeout_seconds=90.0, + ).create("unix_socket") + await adapter.execute( + value, ExternalCheckerMaterialGrant("extract_" + "b" * 32, value.request_digest), + model_grant=grant, + ) + wire = await asyncio.wait_for(received, 1) + assert wire["model_grant"] == { + "grant_id": grant.grant_id, "binding_digest": value.request_digest, + } + assert wire["request"] == value.model_dump(mode="json") + assert grant.grant_id not in json.dumps(wire["request"]) + assert grant.grant_id not in repr(grant) + finally: + server.close() + await server.wait_closed() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("phase,substituted", [("pre_submit", False), ("post_submit", True)]) +async def test_wrong_phase_or_request_cannot_open_model_transport(tmp_path, monkeypatch, phase, substituted): + value = request(phase) + connect = AsyncMock() + monkeypatch.setattr(asyncio, "open_unix_connection", connect) + adapter = external_checker_execution_factory( + socket_path=tmp_path / "unused.sock", timeout_seconds=90.0, + ).create("unix_socket") + grant = ExternalCheckerModelGrant( + "model_" + "a" * 32, "sha256:" + "0" * 64 if substituted else value.request_digest, + ) + with pytest.raises(ExternalServiceProtocolError): + await adapter.execute( + value, ExternalCheckerMaterialGrant("extract_" + "b" * 32, value.request_digest), + model_grant=grant, + ) + connect.assert_not_called() diff --git a/backend/tests/checkers/external/test_policy.py b/backend/tests/checkers/external/test_policy.py index 4893ce7e1..19ad8d4b2 100644 --- a/backend/tests/checkers/external/test_policy.py +++ b/backend/tests/checkers/external/test_policy.py @@ -9,6 +9,9 @@ from app.interfaces.project_agents import ( AtomicGuideRequirement, CapabilityParameter, + CheckInfrastructureFailure, + CheckRuntimeDependency, + CheckSpecification, CompilationStage, PostSubmissionBindingProposal, PreSubmissionBindingProposal, @@ -29,10 +32,16 @@ selected_post_submit_execution_budget_ms, selected_pre_submit_execution_budget_ms, ) +from app.modules.checkers.api.model_calls import ( + ModelGenerationSettings, + ModelPricing, + ProjectModelProfile, + ProjectModelProfileSpec, +) from app.modules.checkers.api.external import EXTERNAL_CHECKER_EXECUTION_CLEANUP_MARGIN_MS from app.modules.artifacts.preparation import ArtifactPreparationLimits from tests.checkers.external.support import registry_spec -from tests.projects.guide_compilation.helpers import result +from tests.projects.guide_compilation.helpers import result, valid_specification def _entry( @@ -86,6 +95,77 @@ async def read_identity(self, **identity): ) from exc +class _Profiles: + def __init__(self, profile): + self.profile = profile + self.calls = [] + + async def read_exact(self, selector, **scope): + self.calls.append((selector, scope)) + return self.profile + + +def _model_configuration_schema() -> ExternalCheckerSchema: + document = { + "type": "object", + "properties": { + "model_profile_id": {"type": "string"}, + "model_profile_version": {"type": "integer"}, + "model_profile_digest": {"type": "string"}, + }, + "required": [ + "model_profile_id", + "model_profile_version", + "model_profile_digest", + ], + "additionalProperties": False, + } + return ExternalCheckerSchema( + schema_id="acme.model.configuration", + schema_version="v1", + document=document, + schema_sha256=external_checker_json_hash(document), + ) + + +def _model_profile(project_id, entry) -> ProjectModelProfile: + profile_id = new_record_id() + pricing = ModelPricing( + pricing_policy_id=new_record_id(), + pricing_policy_version=1, + pricing_policy_digest=external_checker_json_hash({"pricing": "fixed"}), + uncached_input_nanodollars_per_token=3, + cached_input_nanodollars_per_token=1, + output_nanodollars_per_token=7, + ) + spec = ProjectModelProfileSpec( + project_id=project_id, + profile_version=1, + registry_entry_id=entry.registry_entry_id, + registry_entry_digest=entry.entry_digest, + provider="openai_responses", + api="responses.v1", + model_snapshot="gpt-example-2026-10-01", + settings=ModelGenerationSettings(reasoning_effort="medium", text_verbosity="low"), + maximum_request_bytes=10_000, + maximum_output_bytes=4_000, + maximum_response_bytes=30_000, + maximum_input_tokens=100, + maximum_output_tokens=50, + deadline_ms=30_000, + pricing=pricing, + ) + return ProjectModelProfile( + operation_id=uuid4(), + actor_profile_id=uuid4(), + spec=spec, + profile_digest=external_checker_json_hash(spec.model_dump(mode="json")), + profile_id=profile_id, + authorization_evidence_id=uuid4(), + created_at=datetime.now(UTC), + ) + + def _proposal(): pre = AtomicGuideRequirement( requirement_id="safe_archive", @@ -120,6 +200,36 @@ def _proposal(): ) +def _model_proposal(): + proposal = _proposal() + model_specification = CheckSpecification.model_validate( + valid_specification().model_dump(mode="json") + | { + "evaluation_method": "model_evaluation", + "execution_mode": "background", + "stage_rationale": "Model review runs after immutable submission creation.", + "runtime_dependencies": ( + CheckRuntimeDependency( + dependency_id="model_runtime", + kind="model_service", + provisioning="model_proxy", + purpose="Bounded trusted model access for the project image.", + unavailable_code="implementation_unavailable", + ), + ), + "infrastructure_failures": ( + *valid_specification().infrastructure_failures, + CheckInfrastructureFailure( + code="implementation_unavailable", + condition="The trusted model service is unavailable.", + ), + ), + } + ) + post = proposal.requirements[1].model_copy(update={"check_specification": model_specification}) + return proposal.model_copy(update={"requirements": (proposal.requirements[0], post)}) + + @pytest.mark.asyncio async def test_compilation_locks_exact_phase_registry_entries_and_default(): entries = ( @@ -142,6 +252,111 @@ async def test_compilation_locks_exact_phase_registry_entries_and_default(): assert [call["phase"] for call in registry.calls] == ["pre_submit", "post_submit"] +@pytest.mark.asyncio +async def test_model_configuration_requires_exact_post_profile_scope(): + project_id = new_record_id() + pre = _entry("acme.safe_archive", "pre_submit") + post = _entry( + "acme.quality_review", + "post_submit", + _model_configuration_schema(), + ) + profile = _model_profile(project_id, post) + profiles = _Profiles(profile) + proposal = _model_proposal() + binding = proposal.post_submit_bindings[0].model_copy( + update={ + "parameters": ( + CapabilityParameter(name="model_profile_id", value=str(profile.profile_id)), + CapabilityParameter(name="model_profile_version", value=1), + CapabilityParameter(name="model_profile_digest", value=profile.profile_digest), + ) + } + ) + + _, policy = await compile_external_checker_policies( + proposal.model_copy(update={"post_submit_bindings": (binding,)}), + _Registry((pre, post)), + project_id=project_id, + profiles=profiles, + ) + + assert policy.entries[0].configuration == { + "model_profile_id": str(profile.profile_id), + "model_profile_version": 1, + "model_profile_digest": profile.profile_digest, + } + selector, scope = profiles.calls[0] + assert selector == profile.selector + assert scope == { + "project_id": project_id, + "registry_entry_id": post.registry_entry_id, + "registry_entry_digest": post.entry_digest, + } + + +@pytest.mark.asyncio +async def test_model_configuration_rejects_partial_pre_and_substituted_profile(): + project_id = new_record_id() + pre = _entry( + "acme.safe_archive", + "pre_submit", + _model_configuration_schema(), + ) + ordinary_pre = _entry("acme.safe_archive", "pre_submit") + post = _entry( + "acme.quality_review", + "post_submit", + _model_configuration_schema(), + ) + profile = _model_profile(project_id, post) + parameters = ( + CapabilityParameter(name="model_profile_id", value=str(profile.profile_id)), + CapabilityParameter(name="model_profile_version", value=1), + CapabilityParameter(name="model_profile_digest", value=profile.profile_digest), + ) + proposal = _model_proposal() + with pytest.raises(ValueError, match="model evaluation profile"): + await compile_external_checker_policies( + proposal, + _Registry( + ( + ordinary_pre, + _entry("acme.quality_review", "post_submit"), + ) + ), + project_id=project_id, + profiles=_Profiles(profile), + ) + post_binding = proposal.post_submit_bindings[0].model_copy(update={"parameters": parameters}) + partial = post_binding.model_copy(update={"parameters": parameters[:1]}) + with pytest.raises(ValueError, match="configuration"): + await compile_external_checker_policies( + proposal.model_copy(update={"post_submit_bindings": (partial,)}), + _Registry((ordinary_pre, post)), + project_id=project_id, + profiles=_Profiles(profile), + ) + + pre_binding = proposal.pre_submit_bindings[0].model_copy(update={"parameters": parameters}) + with pytest.raises(ValueError, match="configuration"): + await compile_external_checker_policies( + proposal.model_copy(update={"pre_submit_bindings": (pre_binding,)}), + _Registry((pre, post)), + project_id=project_id, + profiles=_Profiles(profile), + ) + + substituted = _model_profile(new_record_id(), post) + with pytest.raises(ValueError, match="unavailable"): + await compile_external_checker_policies( + proposal.model_copy(update={"post_submit_bindings": (post_binding,)}), + _Registry((ordinary_pre, post)), + project_id=project_id, + profiles=_Profiles(substituted), + ) + + @pytest.mark.asyncio async def test_compilation_refuses_missing_or_wrong_phase_registry_identity(): proposal = _proposal() @@ -240,10 +455,13 @@ async def test_selected_post_maximum_fits_default_art_custody_and_rejects_overru ) limits = ArtifactPreparationLimits() - assert selected_post_submit_execution_budget_ms( - policy, - artifact_deadline_ms=int(limits.total_deadline_seconds * 1000), - ) == MAXIMUM_SELECTED_POST_EXECUTION_BUDGET_MS + assert ( + selected_post_submit_execution_budget_ms( + policy, + artifact_deadline_ms=int(limits.total_deadline_seconds * 1000), + ) + == MAXIMUM_SELECTED_POST_EXECUTION_BUDGET_MS + ) assert ( limits.total_deadline_seconds + limits.cleanup_margin_seconds < limits.reservation_ttl_seconds @@ -285,13 +503,17 @@ async def test_pre_configuration_is_schema_checked_and_locked_as_json(): "additionalProperties": False, } schema = ExternalCheckerSchema( - schema_id="acme.pre.configuration", schema_version="v1", document=document, + schema_id="acme.pre.configuration", + schema_version="v1", + document=document, schema_sha256=external_checker_json_hash(document), ) - registry = _Registry(( - _entry("acme.safe_archive", "pre_submit", schema), - _entry("acme.quality_review", "post_submit"), - )) + registry = _Registry( + ( + _entry("acme.safe_archive", "pre_submit", schema), + _entry("acme.quality_review", "post_submit"), + ) + ) proposal = _proposal() parameters = ( CapabilityParameter(name="threshold", value=3), @@ -308,21 +530,26 @@ async def test_pre_configuration_is_schema_checked_and_locked_as_json(): wrong = binding.model_copy(update={"parameters": invalid}) with pytest.raises(ValueError): await compile_external_checker_policies( - proposal.model_copy(update={"pre_submit_bindings": (wrong,)}), registry, + proposal.model_copy(update={"pre_submit_bindings": (wrong,)}), + registry, ) def test_pre_configuration_preserves_retained_empty_binding_and_rejects_duplicates(): binding = _proposal().pre_submit_bindings[0] retained = { - "requirement_id": "safe_archive", "capability_id": "acme.safe_archive", - "capability_version": "v1.2.3", "stage": "pre_submit", + "requirement_id": "safe_archive", + "capability_id": "acme.safe_archive", + "capability_version": "v1.2.3", + "stage": "pre_submit", } assert binding.model_dump(mode="json") == retained assert PreSubmissionBindingProposal.model_validate(retained).model_dump(mode="json") == retained duplicate = CapabilityParameter(name="threshold", value=3) with pytest.raises(ValueError, match="unique"): - PreSubmissionBindingProposal.model_validate(retained | {"parameters": (duplicate, duplicate)}) + PreSubmissionBindingProposal.model_validate( + retained | {"parameters": (duplicate, duplicate)} + ) @pytest.mark.asyncio @@ -333,24 +560,29 @@ async def test_policy_compilation_refuses_unrunnable_post_images(aggregate: bool if aggregate: binding = proposal.post_submit_bindings[0] requirement = next( - item for item in proposal.requirements - if item.requirement_id == binding.requirement_id + item for item in proposal.requirements if item.requirement_id == binding.requirement_id + ) + proposal = proposal.model_copy( + update={ + "requirements": proposal.requirements + + (requirement.model_copy(update={"requirement_id": "secondary_review"}),), + "post_submit_bindings": proposal.post_submit_bindings + + ( + binding.model_copy( + update={ + "requirement_id": "secondary_review", + "capability_id": "acme.secondary_review", + } + ), + ), + } + ) + entries.extend( + [ + _entry("acme.quality_review", "post_submit", deadline_ms=1_700_000), + _entry("acme.secondary_review", "post_submit", deadline_ms=1_700_000), + ] ) - proposal = proposal.model_copy(update={ - "requirements": proposal.requirements + ( - requirement.model_copy(update={"requirement_id": "secondary_review"}), - ), - "post_submit_bindings": proposal.post_submit_bindings + ( - binding.model_copy(update={ - "requirement_id": "secondary_review", - "capability_id": "acme.secondary_review", - }), - ), - }) - entries.extend([ - _entry("acme.quality_review", "post_submit", deadline_ms=1_700_000), - _entry("acme.secondary_review", "post_submit", deadline_ms=1_700_000), - ]) else: entries.append(_entry("acme.quality_review", "post_submit", deadline_ms=3_600_000)) with pytest.raises(ValueError, match="durable worker deadline"): @@ -362,17 +594,21 @@ async def test_policy_compilation_refuses_pre_images_exceeding_platform_custody( proposal = _proposal() binding = proposal.pre_submit_bindings[0] requirement = proposal.requirements[0] - proposal = proposal.model_copy(update={ - "requirements": proposal.requirements + ( - requirement.model_copy(update={"requirement_id": "secondary_archive"}), - ), - "pre_submit_bindings": proposal.pre_submit_bindings + ( - binding.model_copy(update={ - "requirement_id": "secondary_archive", - "capability_id": "acme.secondary_archive", - }), - ), - }) + proposal = proposal.model_copy( + update={ + "requirements": proposal.requirements + + (requirement.model_copy(update={"requirement_id": "secondary_archive"}),), + "pre_submit_bindings": proposal.pre_submit_bindings + + ( + binding.model_copy( + update={ + "requirement_id": "secondary_archive", + "capability_id": "acme.secondary_archive", + } + ), + ), + } + ) entries = [ _entry("acme.safe_archive", "pre_submit", deadline_ms=3_600_000), _entry("acme.secondary_archive", "pre_submit", deadline_ms=3_600_000), diff --git a/backend/tests/checkers/external/test_runtime.py b/backend/tests/checkers/external/test_runtime.py index 43ff7c11e..6728889ae 100644 --- a/backend/tests/checkers/external/test_runtime.py +++ b/backend/tests/checkers/external/test_runtime.py @@ -236,6 +236,7 @@ async def handle(reader, writer): writer.close() return assert received["request"]["request_digest"] == request.request_digest + assert received["model_grant"] is None body = json.dumps( { "protocol_version": "external_checker_service.v1", diff --git a/backend/tests/checkers/model_provider/test_openai_responses.py b/backend/tests/checkers/model_provider/test_openai_responses.py new file mode 100644 index 000000000..2c1a989c6 --- /dev/null +++ b/backend/tests/checkers/model_provider/test_openai_responses.py @@ -0,0 +1,460 @@ +"""Offline OpenAI Responses transport tests for checker model custody.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError +import json + +import httpx2 +from openai import AsyncOpenAI +import pytest + +from app.adapters.checkers.model_provider import ( + OpenAIResponsesCheckerModelProvider, + OpenAIResponsesSettings, + create_checker_model_provider, +) +from app.interfaces.checker_model_provider import ( + CHECKER_MODEL_CAPABILITY_KEY, + MAX_CHECKER_MODEL_OUTPUT_BYTES, + MAX_CHECKER_MODEL_PROMPT_BYTES, + CheckerModelCreateFailure, + CheckerModelCreateOutcome, + CheckerModelObservation, + CheckerModelProviderStatus, + CheckerModelRecoveryOutcome, + CheckerModelRequest, + CheckerModelResponseReference, + CheckerModelUsage, +) + + +def settings(**overrides) -> OpenAIResponsesSettings: + values = { + "api_key": "secret-test-key", + "model": "gpt-5.6-terra", + "maximum_request_bytes": 1_000, + "maximum_input_tokens": 100, + "maximum_output_tokens": 20, + "maximum_output_bytes": 1_000, + "request_timeout_seconds": 3.0, + "reasoning_effort": "medium", + "text_verbosity": "low", + } + values.update(overrides) + return OpenAIResponsesSettings(**values) + + +def completed_response( + *, + response_id: str = "resp_known", + model: str = "gpt-5.6-terra", + status: str = "completed", + text: str = "verbatim output", + usage: dict | None = None, +) -> dict: + body = { + "id": response_id, + "object": "response", + "created_at": 1.0, + "model": model, + "status": status, + "service_tier": "default", + "output": [], + "parallel_tool_calls": False, + "tool_choice": "auto", + "tools": [], + "temperature": None, + "top_p": None, + "usage": usage, + } + if status in {"completed", "failed", "cancelled", "incomplete"} and text: + body["output"] = [ + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": text, "annotations": []}], + } + ] + return body + + +def usage(**overrides) -> dict: + values = { + "input_tokens": 8, + "input_tokens_details": {"cached_tokens": 3, "cache_write_tokens": 0}, + "output_tokens": 5, + "output_tokens_details": {"reasoning_tokens": 2}, + "total_tokens": 13, + } + values.update(overrides) + return values + + +def adapter(handler, *, profile=None): + http_client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) + client = AsyncOpenAI( + api_key="secret-test-key", + base_url="https://provider.invalid/v1", + http_client=http_client, + max_retries=0, + ) + return OpenAIResponsesCheckerModelProvider(profile or settings(), client) + + +def test_dtos_are_immutable_bounded_and_secret_safe() -> None: + request = CheckerModelRequest("private prompt") + observed = CheckerModelObservation( + CheckerModelResponseReference("resp_known"), + "gpt-5.6-terra", + CheckerModelProviderStatus.COMPLETED, + "private output", + CheckerModelUsage(8, 3, 5, 2, 13), + ) + configured = settings() + + assert "private prompt" not in repr(request) + assert "private output" not in repr(observed) + assert "secret-test-key" not in repr(configured) + with pytest.raises(FrozenInstanceError): + request.prompt = "changed" # type: ignore[misc] + with pytest.raises(ValueError, match="prompt"): + CheckerModelRequest("x" * (MAX_CHECKER_MODEL_PROMPT_BYTES + 1)) + with pytest.raises(ValueError, match="usage"): + CheckerModelUsage(8, 9, 5, 2, 13) + with pytest.raises(ValueError, match="usage"): + CheckerModelUsage(8, 3, 5, 6, 13) + with pytest.raises(ValueError, match="usage"): + CheckerModelUsage(8, 3, 5, 2, 12) + with pytest.raises(ValueError, match="settings"): + settings(model="unusable/model") + with pytest.raises(ValueError, match="settings"): + settings(maximum_request_bytes=MAX_CHECKER_MODEL_PROMPT_BYTES + 1) + with pytest.raises(ValueError, match="settings"): + settings(maximum_output_bytes=MAX_CHECKER_MODEL_OUTPUT_BYTES + 1) + + +async def test_create_counts_then_dispatches_one_stored_background_response() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + if request.url.path.endswith("/responses/input_tokens"): + return httpx2.Response( + 200, + json={"object": "response.input_tokens", "input_tokens": 8}, + ) + return httpx2.Response(200, json=completed_response(usage=usage())) + + provider = adapter(handler) + result = await provider.create(CheckerModelRequest("inspect this package")) + await provider.aclose() + + assert result.outcome is CheckerModelCreateOutcome.ACCEPTED + assert result.failure is None + assert result.known_response == CheckerModelResponseReference("resp_known") + assert result.observation is not None + assert result.observation.provider_model == "gpt-5.6-terra" + assert result.observation.output_text == "verbatim output" + assert result.observation.usage == CheckerModelUsage(8, 3, 5, 2, 13) + assert [request.url.path for request in requests] == [ + "/v1/responses/input_tokens", + "/v1/responses", + ] + counted, created = (json.loads(request.content) for request in requests) + for body in (counted, created): + assert body["model"] == "gpt-5.6-terra" + assert body["input"] == "inspect this package" + assert body["reasoning"] == {"effort": "medium"} + assert body["text"] == {"verbosity": "low"} + assert body["parallel_tool_calls"] is False + assert body["tools"] == [] + assert body["truncation"] == "disabled" + assert created["background"] is True + assert created["store"] is True + assert created["service_tier"] == "default" + assert created["max_output_tokens"] == 20 + + +@pytest.mark.parametrize( + ("model_request", "count_response", "failure"), + [ + ( + CheckerModelRequest("x" * 11), + None, + CheckerModelCreateFailure.REQUEST_BYTES_EXCEEDED, + ), + ( + CheckerModelRequest("bounded"), + httpx2.Response(503), + CheckerModelCreateFailure.INPUT_COUNT_UNAVAILABLE, + ), + ( + CheckerModelRequest("bounded"), + httpx2.Response( + 200, + json={"object": "response.input_tokens", "input_tokens": 101}, + ), + CheckerModelCreateFailure.INPUT_LIMIT_EXCEEDED, + ), + ], + ids=["request-exceeds-profile", "count-unavailable", "count-exceeds-profile"], +) +async def test_precreate_count_failures_prove_no_create_effect( + model_request: CheckerModelRequest, + count_response: httpx2.Response | None, + failure: CheckerModelCreateFailure, +) -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + assert count_response is not None + return count_response + + provider = adapter(handler, profile=settings(maximum_request_bytes=10)) + result = await provider.create(model_request) + await provider.aclose() + + assert result.outcome is CheckerModelCreateOutcome.NO_EFFECT + assert result.failure is failure + assert result.known_response is None + expected_paths = [] if count_response is None else ["/v1/responses/input_tokens"] + assert [request.url.path for request in requests] == expected_paths + + +async def test_lost_create_is_uncertain_and_never_retried() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + if request.url.path.endswith("/responses/input_tokens"): + return httpx2.Response( + 200, + json={"object": "response.input_tokens", "input_tokens": 8}, + ) + raise httpx2.ReadTimeout("lost create response", request=request) + + provider = adapter(handler) + result = await provider.create(CheckerModelRequest("one paid effect")) + await provider.aclose() + + assert result.outcome is CheckerModelCreateOutcome.UNCERTAIN + assert result.failure is CheckerModelCreateFailure.DELIVERY_UNCERTAIN + assert result.known_response is None + assert [request.url.path for request in requests] == [ + "/v1/responses/input_tokens", + "/v1/responses", + ] + + +async def test_create_http_rejection_remains_uncertain_and_never_retried() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + if request.url.path.endswith("/responses/input_tokens"): + return httpx2.Response( + 200, + json={"object": "response.input_tokens", "input_tokens": 8}, + ) + return httpx2.Response( + 400, + json={ + "error": { + "message": "provider detail must remain private", + "type": "invalid_request_error", + } + }, + ) + + provider = adapter(handler) + result = await provider.create(CheckerModelRequest("one paid effect")) + await provider.aclose() + + assert result.outcome is CheckerModelCreateOutcome.UNCERTAIN + assert result.failure is CheckerModelCreateFailure.DELIVERY_UNCERTAIN + assert "provider detail" not in repr(result) + assert [request.url.path for request in requests] == [ + "/v1/responses/input_tokens", + "/v1/responses", + ] + + +async def test_recover_uses_only_the_known_response_id() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response( + 200, + json=completed_response( + status="queued", + text="", + usage=None, + ), + ) + + provider = adapter(handler) + reference = CheckerModelResponseReference("resp_known") + result = await provider.recover(reference) + await provider.aclose() + + assert result.outcome is CheckerModelRecoveryOutcome.OBSERVED + assert result.reference == reference + assert result.observation is not None + assert result.observation.status is CheckerModelProviderStatus.QUEUED + assert len(requests) == 1 + assert requests[0].method == "GET" + assert requests[0].url.path == "/v1/responses/resp_known" + + +@pytest.mark.parametrize( + ("body", "profile_overrides"), + [ + ( + completed_response( + usage=usage( + input_tokens_details={ + "cached_tokens": 9, + "cache_write_tokens": 0, + } + ) + ), + {}, + ), + (completed_response(usage=usage(total_tokens=12)), {}), + ( + completed_response(usage=usage(input_tokens=101, total_tokens=106)), + {}, + ), + ( + completed_response(usage=usage(output_tokens=21, total_tokens=29)), + {}, + ), + ( + completed_response(usage=usage(output_tokens_details={"reasoning_tokens": 6})), + {}, + ), + (completed_response(model="gpt-5.6-terra-other", usage=usage()), {}), + ( + completed_response(text="x" * 11, usage=usage()), + {"maximum_output_bytes": 10}, + ), + (completed_response(response_id="", usage=usage()), {}), + (completed_response(usage=usage()) | {"service_tier": "priority"}, {}), + (completed_response(usage=usage()) | {"service_tier": None}, {}), + ], + ids=[ + "cached-exceeds-input", + "total-mismatch", + "input-exceeds-profile", + "output-exceeds-profile", + "reasoning-exceeds-output", + "model-mismatch", + "output-byte-bound", + "missing-response-id", + "unexpected-pricing-tier", + "missing-pricing-tier", + ], +) +async def test_malformed_create_response_is_uncertain_and_never_releases_effect( + body: dict, + profile_overrides: dict, +) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + if request.url.path.endswith("/responses/input_tokens"): + return httpx2.Response( + 200, + json={"object": "response.input_tokens", "input_tokens": 8}, + ) + return httpx2.Response(200, json=body) + + provider = adapter(handler, profile=settings(**profile_overrides)) + result = await provider.create(CheckerModelRequest("one effect")) + await provider.aclose() + + assert result.outcome is CheckerModelCreateOutcome.UNCERTAIN + assert result.failure is CheckerModelCreateFailure.RESPONSE_INVALID + if body["id"]: + assert result.known_response == CheckerModelResponseReference(body["id"]) + else: + assert result.known_response is None + + +async def test_recovery_protocol_failure_is_invalid_without_create() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response( + 200, + json=completed_response(model="wrong-model", usage=usage()), + ) + + provider = adapter(handler) + reference = CheckerModelResponseReference("resp_known") + result = await provider.recover(reference) + await provider.aclose() + + assert result.outcome is CheckerModelRecoveryOutcome.INVALID + assert result.reference == reference + assert result.observation is None + assert len(requests) == 1 and requests[0].method == "GET" + + +@pytest.mark.parametrize("status", ["failed", "cancelled", "incomplete"]) +async def test_known_terminal_failures_remain_accepted_effects(status: str) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + if request.url.path.endswith("/responses/input_tokens"): + return httpx2.Response( + 200, + json={"object": "response.input_tokens", "input_tokens": 8}, + ) + return httpx2.Response( + 200, + json=completed_response(status=status, text="", usage=None), + ) + + provider = adapter(handler) + result = await provider.create(CheckerModelRequest("one effect")) + await provider.aclose() + + assert result.outcome is CheckerModelCreateOutcome.ACCEPTED + assert result.observation is not None + assert result.observation.status.value == status + assert result.observation.usage is None + + +async def test_factory_registers_one_zero_retry_openai_provider(monkeypatch) -> None: + constructed: list[dict] = [] + + class FakeInputTokens: + async def count(self, **kwargs): + raise AssertionError("factory test must not call provider") + + class FakeResponses: + input_tokens = FakeInputTokens() + + class FakeClient: + responses = FakeResponses() + + async def close(self): + return None + + def client(**kwargs): + constructed.append(kwargs) + return FakeClient() + + monkeypatch.setattr("openai.AsyncOpenAI", client) + provider = create_checker_model_provider(settings()) + + assert provider.identity.capability_key == CHECKER_MODEL_CAPABILITY_KEY + assert provider.identity.provider_key == "openai_responses" + assert len(constructed) == 1 + assert constructed[0]["max_retries"] == 0 + assert constructed[0]["timeout"] == 3.0 + assert constructed[0]["api_key"] == "secret-test-key" + await provider.aclose() diff --git a/backend/tests/checkers_model_calls/conftest.py b/backend/tests/checkers_model_calls/conftest.py new file mode 100644 index 000000000..6f405723b --- /dev/null +++ b/backend/tests/checkers_model_calls/conftest.py @@ -0,0 +1,11 @@ +"""Explicit signed public Operator fixtures for model controls and composed execution.""" + +from tests.authentication.fixtures import ( + auth_database_env as auth_database_env, + clear_settings_cache as clear_settings_cache, + rsa_signing_material as rsa_signing_material, +) +from tests.authorization.admin_access.fixtures import ( + signed_access as signed_access, + admin_access as admin_access, +) diff --git a/backend/tests/checkers_model_calls/test_composed_execution.py b/backend/tests/checkers_model_calls/test_composed_execution.py new file mode 100644 index 000000000..766282018 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_composed_execution.py @@ -0,0 +1,195 @@ +"""Real approval, AUTH, PostgreSQL and ART with an offline provider and image boundary.""" + +from contextlib import asynccontextmanager +from uuid import UUID, uuid4 + +from sqlalchemy import select, func + +from app.adapters.artifacts import checker_output_binding, checker_output_storage +from app.adapters.auth import checker_model_service_authorization +from app.adapters.checkers import model_call_service, model_invocation_grants, model_profile_reader +from app.adapters.checkers.model_execution import ModelAwareExternalExecution +from app.interfaces.checker_model_provider import ( + CheckerModelCreateOutcome, + CheckerModelCreateResult, + CheckerModelObservation, + CheckerModelProviderStatus, + CheckerModelResponseReference, + CheckerModelUsage, +) +from app.interfaces.checker_model_proxy import ModelProxyRequest +from app.interfaces.external_services import ExternalServiceAdapterIdentity +from app.interfaces.project_agents import CapabilityParameter +from app.modules.actors.api import ServiceIdentity +from app.modules.checkers.api.external import ExternalCheckerSchema, external_checker_json_hash +from app.modules.checkers.model_calls.models import ( + CheckerModelCallAttempt, + CheckerModelBudgetAccount, +) +from app.modules.checkers.model_calls.proxy import CheckerModelProxy +from tests.checkers.external.support import post_registry_spec +from tests.checkers.execution.support import ( + ControlledSelectedExecution, + live_executor, + reserve, + provision_post_submit_service, +) +from tests.checkers_model_calls.test_public_api import profile_spec +from tests.post_submit_materialization_helpers import material_fixture + + +def model_registry_spec(): + spec = post_registry_spec() + document = { + "type": "object", + "additionalProperties": False, + "properties": { + "model_profile_id": {"type": "string"}, + "model_profile_version": {"type": "integer"}, + "model_profile_digest": {"type": "string"}, + }, + "required": ["model_profile_id", "model_profile_version", "model_profile_digest"], + } + return spec.model_copy( + update={ + "configuration_schema": ExternalCheckerSchema( + schema_id="acme.model_review.configuration", + schema_version="v1", + document=document, + schema_sha256=external_checker_json_hash(document), + ) + } + ) + + +async def test_public_profile_to_private_model_call_and_completed_checker( + tmp_path, + isolated_database_env, + admin_access, +): + await admin_access.signed.grant(admin_access.admin, admin_access.target, role="operator") + profiles = [] + + async def publish(_factory, project_id, registry): + spec = profile_spec(project_id).model_copy( + update={ + "registry_entry_id": registry.registry_entry_id, + "registry_entry_digest": registry.entry_digest, + } + ) + response = await admin_access.signed.client.post( + f"/api/v1/operations/projects/{project_id}/model-profiles", + headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, + json=spec.model_dump(mode="json"), + ) + assert response.status_code == 201, response.text + profile = response.json() + profiles.append(profile) + budget = await admin_access.signed.client.post( + f"/api/v1/operations/projects/{project_id}/model-budget-configurations", + headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, + json={ + "project_id": str(project_id), + "configuration_version": 1, + "lifetime_limit_nanodollars": 10_000_000_000, + }, + ) + assert budget.status_code == 201, budget.text + return tuple( + CapabilityParameter(name=key, value=value) + for key, value in { + "model_profile_id": profile["profile_id"], + "model_profile_version": profile["spec"]["profile_version"], + "model_profile_digest": profile["profile_digest"], + }.items() + ) + + async with material_fixture( + tmp_path, + isolated_database_env, + post_submit_registry_spec=model_registry_spec(), + post_submit_binding_setup=publish, + ) as h: + await provision_post_submit_service(h.factory, ServiceIdentity.ARTIFACT_CHECKER_OUTPUT) + creates = [] + + class Provider: + async def create(self, request): + creates.append(request.prompt) + reference = CheckerModelResponseReference("resp_composed") + return CheckerModelCreateResult( + CheckerModelCreateOutcome.ACCEPTED, + known_response=reference, + observation=CheckerModelObservation( + reference, + profiles[0]["spec"]["model_snapshot"], + CheckerModelProviderStatus.COMPLETED, + '{"verdict":"passed"}', + CheckerModelUsage(20, 5, 10, 2, 30), + ), + ) + + async def recover(self, _): + raise AssertionError("completed call must use its retained ART response") + + @asynccontextmanager + async def provider(_): + yield Provider() + + proxy = CheckerModelProxy( + sessions=h.factory, + authorization=checker_model_service_authorization, + grants=model_invocation_grants, + calls=model_call_service, + profiles=model_profile_reader, + outputs=checker_output_storage( + sessions=h.factory, + store=h.store, + namespace=h.namespace, + preparation=h.preparation, + settings=h.settings, + ), + bindings=lambda session: checker_output_binding(session, namespace=h.namespace), + provider=provider, + ) + + class Image(ControlledSelectedExecution): + identity = ExternalServiceAdapterIdentity("external_checker_execution", "rust_unix") + + async def execute(self, request, grant, *, model_grant=None): + assert model_grant is not None + reply = await proxy.handle( + ModelProxyRequest( + model_grant.grant_id, + request.request_digest, + "Evaluate the verified work", + ) + ) + assert reply.outcome == "completed", reply.failure_code + assert reply.output == '{"verdict":"passed"}' + return await super().execute(request, grant) + + await reserve(h) + executor = live_executor( + h, + selected_execution=ModelAwareExternalExecution( + execution=Image(), + sessions=h.factory, + ), + ) + result = await executor.evaluate_post_submission(h.request) + assert result.outcome == "completed" + assert len(result.execution.external_members) == 1 + assert await executor.evaluate_post_submission(h.request) == result + assert len(creates) == 1 + async with h.factory() as session: + assert ( + await session.scalar(select(func.count()).select_from(CheckerModelCallAttempt)) == 1 + ) + attempt = await session.scalar(select(CheckerModelCallAttempt)) + account = await session.get(CheckerModelBudgetAccount, str(h.request.project_id)) + assert attempt.state == "completed" + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == 15 * 11 + 5 * 2 + 10 * 29 + assert UUID(attempt.response_binding_id).version == 7 + assert UUID(attempt.request_binding_id).version == 7 diff --git a/backend/tests/checkers_model_calls/test_model_call_contract.py b/backend/tests/checkers_model_calls/test_model_call_contract.py new file mode 100644 index 000000000..edd5142aa --- /dev/null +++ b/backend/tests/checkers_model_calls/test_model_call_contract.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from uuid import uuid4 + +from pydantic import ValidationError +import pytest + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.checkers.api.model_calls import ( + ModelCallConflict, + ModelCallUsage, + ModelGenerationSettings, + ModelProfileProvisioningFacts, + ModelPaidSlotKey, + ModelPricing, + ProjectModelProfileSpec, + model_usage_cost, +) + + +def sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +def profile_spec() -> ProjectModelProfileSpec: + return ProjectModelProfileSpec( + project_id=new_record_id(), + profile_version=3, + registry_entry_id=new_record_id(), + registry_entry_digest=sha("registry"), + provider="openai_responses", + api="responses.v1", + model_snapshot="gpt-example-2026-10-01", + settings=ModelGenerationSettings( + reasoning_effort="medium", + text_verbosity="low", + ), + maximum_request_bytes=10_000, + maximum_output_bytes=2_000, + maximum_response_bytes=20_000, + maximum_input_tokens=100, + maximum_output_tokens=50, + deadline_ms=60_000, + pricing=ModelPricing( + pricing_policy_id=new_record_id(), + pricing_policy_version=7, + pricing_policy_digest=sha("pricing"), + uncached_input_nanodollars_per_token=11, + cached_input_nanodollars_per_token=2, + output_nanodollars_per_token=29, + ), + ) + + +def test_reservation_uses_worst_input_rate_and_single_output_rate() -> None: + spec = profile_spec() + + assert spec.maximum_reservation_nanodollars == 100 * 11 + 50 * 29 + + +def test_profile_requires_room_for_canonical_response_evidence() -> None: + with pytest.raises(ValidationError, match="cannot preserve"): + ProjectModelProfileSpec.model_validate( + profile_spec().model_dump() + | {"maximum_output_bytes": 15_000, "maximum_response_bytes": 20_000} + ) + + +def test_profile_rejects_unregistered_provider_or_api_aliases() -> None: + for changes in ({"provider": "openai"}, {"api": "responses"}): + with pytest.raises(ValidationError, match="provider and API are unsupported"): + ProjectModelProfileSpec.model_validate(profile_spec().model_dump() | changes) + + +def test_settlement_prices_cached_input_separately_and_reasoning_once() -> None: + spec = profile_spec() + usage = ModelCallUsage( + input_tokens=80, + cached_input_tokens=30, + output_tokens=20, + reasoning_tokens=17, + ) + + assert model_usage_cost(spec, usage) == 50 * 11 + 30 * 2 + 20 * 29 + + +@pytest.mark.parametrize( + "usage", + [ + ModelCallUsage( + input_tokens=101, + cached_input_tokens=0, + output_tokens=1, + reasoning_tokens=0, + ), + ModelCallUsage( + input_tokens=1, + cached_input_tokens=0, + output_tokens=51, + reasoning_tokens=0, + ), + ], +) +def test_settlement_rejects_usage_above_locked_profile( + usage: ModelCallUsage, +) -> None: + with pytest.raises(ModelCallConflict, match="exceeds the approved profile"): + model_usage_cost(profile_spec(), usage) + + +def test_usage_requires_nested_cached_and_reasoning_classes() -> None: + with pytest.raises(ValidationError): + ModelCallUsage( + input_tokens=1, + cached_input_tokens=2, + output_tokens=1, + reasoning_tokens=0, + ) + with pytest.raises(ValidationError): + ModelCallUsage( + input_tokens=1, + cached_input_tokens=0, + output_tokens=1, + reasoning_tokens=2, + ) + + +def test_stable_slot_identity_has_no_lease_or_evaluation_generation() -> None: + spec = profile_spec() + slot = ModelPaidSlotKey( + project_id=spec.project_id, + assignment_id=new_record_id(), + submission_id=new_record_id(), + submission_version=2, + requirement_id="model_output_quality", + registry_entry_digest=spec.registry_entry_digest, + configuration_sha256=sha("configuration"), + profile_id=new_record_id(), + profile_version=spec.profile_version, + profile_digest=sha("profile"), + ) + + assert set(type(slot).model_fields) == { + "project_id", + "assignment_id", + "submission_id", + "submission_version", + "requirement_id", + "registry_entry_digest", + "configuration_sha256", + "profile_id", + "profile_version", + "profile_digest", + "slot_index", + } + + +def test_profile_settings_reject_prompt_or_unapproved_provider_parameters() -> None: + with pytest.raises(ValidationError): + ModelGenerationSettings.model_validate( + { + "temperature_millionths": 0, + "reasoning_effort": "medium", + "text_verbosity": "low", + "prompt": "must not be retained in an operator profile", + } + ) + + +def test_caller_operation_key_may_be_uuid4_and_record_id_is_server_output() -> None: + spec = profile_spec() + facts = ModelProfileProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=spec, + profile_digest=canonical_json_hash(spec.model_dump(mode="json")), + ) + + assert facts.operation_id.version == 4 + assert "profile_id" not in type(facts).model_fields diff --git a/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py new file mode 100644 index 000000000..781d74a45 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py @@ -0,0 +1,976 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +import asyncio +import json +from pathlib import Path +import runpy +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.authorization.domain.checker_models import ( + BUDGET_CONFIGURE, + PROFILE_PROVISION, + project_model_admin_resource, +) +from app.modules.authorization.domain.resource_digest import authorization_resource_digest +from app.modules.checkers.api.model_calls import ( + KnownNoEffectProof, + ModelActionEvidence, + ModelBudgetProvisioningFacts, + ModelCallBudgetUnavailable, + ModelCallConflict, + ModelCallEvidence, + ModelCallExecutionFacts, + ModelCallUsage, + ModelCallUnavailable, + ModelGenerationSettings, + ModelPaidSlotKey, + ModelPricing, + ModelProfileProvisioningFacts, + ProjectModelBudgetConfigurationSpec, + ProjectModelProfileSpec, + model_action_digest, + model_retry_terminal_digest, +) +from app.modules.checkers.model_calls.models import CheckerModelBudgetAccount +from app.modules.checkers.model_calls.profiles import ModelProfileReader +from app.modules.checkers.model_calls.service import ModelCallService + + +def sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +@dataclass(frozen=True) +class DatabaseFacts: + engine: AsyncEngine + project_id: UUID + task_id: UUID + assignment_id: UUID + submission_id: UUID + registry_entry_id: UUID + registry_entry_digest: str + + +@pytest.fixture +async def model_database(postgres_database_url: str): + schema = f"model_calls_{uuid4().hex}" + engine = create_async_engine( + postgres_database_url, + connect_args={"server_settings": {"search_path": schema}}, + ) + project_id = new_record_id() + task_id = new_record_id() + assignment_id = new_record_id() + submission_id = new_record_id() + registry_entry_id = new_record_id() + registry_entry_digest = sha("registry") + migration = runpy.run_path( + str(Path(__file__).resolve().parents[2] / "alembic/versions/0059_checker_model_runtime.py") + ) + + def isolated(statement: str) -> str: + return statement.replace("public.", f'"{schema}".').replace( + "pg_catalog,public,pg_temp", f'pg_catalog,"{schema}",pg_temp' + ) + + async with engine.begin() as connection: + await connection.execute(text(f'create schema "{schema}"')) + for statement in ( + "create table projects(id uuid primary key)", + "create table workstream_tasks(id uuid primary key, project_id uuid not null, " + "unique(id,project_id))", + "create table task_assignments(id uuid primary key, task_id uuid not null, " + "project_id uuid not null)", + "create table submissions(id uuid primary key, task_id uuid not null, " + "version integer not null, unique(id,task_id,version))", + "create table external_checker_registry_entries(id uuid primary key)", + "create table checker_runs(id uuid primary key)", + "create table actor_profiles(id uuid primary key)", + "create table audit_events(id uuid primary key, event_domain text, event_type text, " + "actor_ref_kind text, actor_id text, action_id text, permission_id text, " + "denial_code text, after_facts jsonb)", + "create table artifact_bindings(id uuid primary key)", + "create table artifact_contents(id uuid primary key)", + "create table artifact_put_attempts(id uuid primary key)", + "create table artifact_verification_receipts(id uuid primary key)", + ): + await connection.execute(text(statement)) + for statement in migration["_TABLES"]: + await connection.execute(text(isolated(statement))) + for statement in migration["_FUNCTIONS"]: + await connection.execute(text(isolated(statement))) + for statement in migration["_TRIGGERS"]: + await connection.execute(text(isolated(statement))) + await connection.execute(text("insert into projects(id) values (:id)"), {"id": project_id}) + await connection.execute( + text("insert into workstream_tasks(id,project_id) values (:id,:project)"), + {"id": task_id, "project": project_id}, + ) + await connection.execute( + text("insert into task_assignments(id,task_id,project_id) values (:id,:task,:project)"), + {"id": assignment_id, "task": task_id, "project": project_id}, + ) + await connection.execute( + text("insert into submissions(id,task_id,version) values (:id,:task,1)"), + {"id": submission_id, "task": task_id}, + ) + await connection.execute( + text("insert into external_checker_registry_entries(id) values (:id)"), + {"id": registry_entry_id}, + ) + try: + yield DatabaseFacts( + engine, + project_id, + task_id, + assignment_id, + submission_id, + registry_entry_id, + registry_entry_digest, + ) + finally: + try: + async with engine.begin() as connection: + await connection.execute(text("set local search_path = pg_catalog")) + await connection.execute(text(f'drop schema if exists "{schema}" cascade')) + finally: + await engine.dispose() + + +@dataclass +class BoundaryPorts: + decisions: dict[str, UUID] = field(default_factory=dict) + fresh_authority_evidence: bool = True + request_checks: int = 0 + response_checks: int = 0 + current_checks: int = 0 + events: list[str] = field(default_factory=list) + corrupt_admin_receipt: bool = False + + def _decision(self, action: str, facts: object) -> ModelActionEvidence: + self.events.append(action) + digest = model_action_digest(action, facts) + evidence_id = ( + new_record_id() + if self.fresh_authority_evidence + else self.decisions.setdefault(digest, new_record_id()) + ) + return ModelActionEvidence(evidence_id=evidence_id, facts_digest=digest) + + async def authorize_profile(self, facts): + return self._decision("profile", facts) + + async def authorize_budget(self, facts): + return self._decision("budget", facts) + + async def authorize_prepare(self, execution, slot, request_sha256): + return self._decision( + "prepare", + { + "execution": execution.model_dump(mode="json"), + "slot": slot.model_dump(mode="json"), + "request_sha256": request_sha256, + }, + ) + + async def authorize_dispatch(self, execution, attempt, request_evidence): + return self._decision( + "dispatch", + { + "execution": execution.model_dump(mode="json"), + "attempt": attempt.model_dump(mode="json"), + "request_evidence": request_evidence.model_dump(mode="json"), + }, + ) + + async def authorize_recover(self, execution, effect): + return self._decision( + "recover", + { + "execution": execution.model_dump(mode="json"), + "effect": effect.model_dump(mode="json"), + }, + ) + + async def authorize_observe(self, effect, provider_response_id, observation_digest): + return self._decision( + "observe", + { + "effect": effect.model_dump(mode="json"), + "provider_response_id": provider_response_id, + "observation_digest": observation_digest, + }, + ) + + async def authorize_child(self, execution, parent_attempt_id, terminal_digest): + return self._decision( + "child", + { + "execution": execution.model_dump(mode="json"), + "parent_attempt_id": str(parent_attempt_id), + "terminal_digest": terminal_digest, + }, + ) + + async def authorize_complete(self, effect, response_evidence, terminal_digest): + return self._decision( + "complete", + { + "effect": effect.model_dump(mode="json"), + "response_evidence": ( + response_evidence.model_dump(mode="json") + if response_evidence is not None + else None + ), + "terminal_digest": terminal_digest, + }, + ) + + async def require_current(self, facts, slot): + self.current_checks += 1 + self.events.append("current") + + async def require_request(self, attempt, evidence): + self.request_checks += 1 + + async def require_response(self, attempt_id, evidence): + self.response_checks += 1 + + +class DatabaseBoundaryPorts: + """Persist fake boundary receipts so isolated PostgreSQL exercises real ancestry FKs.""" + + def __init__(self, session: AsyncSession, ports: BoundaryPorts): + self._session = session + self._ports = ports + + async def _authority( + self, evidence, *, action: str, permission: str, actor_id=None, resource_digest=None + ): + actor = str(actor_id or new_record_id()) + if actor_id is not None: + await self._session.execute( + text("insert into actor_profiles(id) values (:id) on conflict do nothing"), + {"id": actor_id}, + ) + await self._session.execute( + text( + "insert into audit_events(id,event_domain,event_type,actor_ref_kind,actor_id," + "action_id,permission_id,denial_code,after_facts) values " + "(:id,'authority','SensitiveAuthorizationAllowed','actor_profile',:actor," + ":action,:permission,null,cast(:after as jsonb))" + ), + { + "id": evidence.evidence_id, + "actor": actor, + "action": action, + "permission": permission, + "after": json.dumps( + { + "allowed": True, + "resource_context_digest": ( + sha("substituted-authority") + if actor_id is not None and self._ports.corrupt_admin_receipt + else resource_digest or sha("authority") + ), + } + ), + }, + ) + return evidence + + async def authorize_profile(self, facts): + evidence = await self._ports.authorize_profile(facts) + return await self._authority( + evidence, + action="project.model_profile.provision", + permission="project.model_profile.manage", + actor_id=facts.actor_profile_id, + resource_digest=authorization_resource_digest( + project_model_admin_resource( + PROFILE_PROVISION, facts, actor_profile_id=facts.actor_profile_id + ) + ), + ) + + async def authorize_budget(self, facts): + evidence = await self._ports.authorize_budget(facts) + return await self._authority( + evidence, + action="project.model_budget.configure", + permission="project.model_budget.manage", + actor_id=facts.actor_profile_id, + resource_digest=authorization_resource_digest( + project_model_admin_resource( + BUDGET_CONFIGURE, facts, actor_profile_id=facts.actor_profile_id + ) + ), + ) + + async def authorize_prepare(self, execution, slot, request_sha256): + evidence = await self._ports.authorize_prepare(execution, slot, request_sha256) + return await self._authority( + evidence, action="checker.model_call.prepare", permission="checker.model_call.prepare" + ) + + async def authorize_dispatch(self, execution, attempt, request_evidence): + evidence = await self._ports.authorize_dispatch(execution, attempt, request_evidence) + return await self._authority( + evidence, + action="checker.model_call.dispatch", + permission="checker.model_call.dispatch", + ) + + async def authorize_recover(self, execution, effect): + evidence = await self._ports.authorize_recover(execution, effect) + return await self._authority( + evidence, action="checker.model_call.recover", permission="checker.model_call.recover" + ) + + async def authorize_observe(self, effect, provider_response_id, observation_digest): + evidence = await self._ports.authorize_observe( + effect, provider_response_id, observation_digest + ) + return await self._authority( + evidence, action="checker.model_call.observe", permission="checker.model_call.observe" + ) + + async def authorize_child(self, execution, parent_attempt_id, terminal_digest): + evidence = await self._ports.authorize_child( + execution, parent_attempt_id, terminal_digest + ) + return await self._authority( + evidence, + action="project.model_call.recovery_child.authorize", + permission="project.model_call.recovery_child.authorize", + ) + + async def authorize_complete(self, effect, response_evidence, terminal_digest): + evidence = await self._ports.authorize_complete( + effect, response_evidence, terminal_digest + ) + return await self._authority( + evidence, + action="checker.model_call.complete", + permission="checker.model_call.complete", + ) + + async def require_current(self, facts, slot): + await self._ports.require_current(facts, slot) + + async def _artifact(self, evidence): + for table_name, value in ( + ("artifact_bindings", evidence.binding_id), + ("artifact_contents", evidence.content_id), + ("artifact_put_attempts", evidence.put_attempt_id), + ("artifact_verification_receipts", evidence.verification_receipt_id), + ): + await self._session.execute( + text(f"insert into {table_name}(id) values (:id) on conflict do nothing"), + {"id": value}, + ) + + async def require_request(self, attempt, evidence): + await self._ports.require_request(attempt, evidence) + await self._artifact(evidence) + + async def require_response(self, attempt_id, evidence): + await self._ports.require_response(attempt_id, evidence) + await self._artifact(evidence) + + +def service(session: AsyncSession, ports: BoundaryPorts) -> ModelCallService: + boundaries = DatabaseBoundaryPorts(session, ports) + return ModelCallService( + session, + authority=boundaries, + current_execution=boundaries, + evidence=boundaries, + ) + + +async def provision( + facts: DatabaseFacts, + ports: BoundaryPorts, + *, + limit: int, + replay: bool = False, +): + profile_spec = ProjectModelProfileSpec( + project_id=facts.project_id, + profile_version=1, + registry_entry_id=facts.registry_entry_id, + registry_entry_digest=facts.registry_entry_digest, + provider="openai_responses", + api="responses.v1", + model_snapshot="gpt-example-2026-10-01", + settings=ModelGenerationSettings(reasoning_effort="medium", text_verbosity="low"), + maximum_request_bytes=10_000, + maximum_output_bytes=4_000, + maximum_response_bytes=30_000, + maximum_input_tokens=100, + maximum_output_tokens=50, + deadline_ms=30_000, + pricing=ModelPricing( + pricing_policy_id=new_record_id(), + pricing_policy_version=1, + pricing_policy_digest=sha("pricing"), + uncached_input_nanodollars_per_token=11, + cached_input_nanodollars_per_token=2, + output_nanodollars_per_token=29, + ), + ) + profile_facts = ModelProfileProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=profile_spec, + profile_digest=canonical_json_hash(profile_spec.model_dump(mode="json")), + ) + budget_spec = ProjectModelBudgetConfigurationSpec( + project_id=facts.project_id, + configuration_version=1, + lifetime_limit_nanodollars=limit, + ) + budget_facts = ModelBudgetProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=budget_spec, + configuration_digest=canonical_json_hash(budget_spec.model_dump(mode="json")), + ) + async with AsyncSession(facts.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + profile = await owner.provision_profile(profile_facts) + budget = await owner.provision_budget(budget_facts) + if replay: + replayed_profile = await owner.provision_profile(profile_facts) + replayed_budget = await owner.provision_budget(budget_facts) + assert replayed_profile.authorization_evidence_id == ( + profile.authorization_evidence_id + ) + assert replayed_budget.authorization_evidence_id == ( + budget.authorization_evidence_id + ) + return profile + + +async def test_provisioning_replay_retains_original_receipts_with_fresh_authority( + model_database: DatabaseFacts, +) -> None: + await provision(model_database, BoundaryPorts(), limit=10_000, replay=True) + + +async def test_profile_publication_rejects_substituted_authority_digest( + model_database: DatabaseFacts, +) -> None: + with pytest.raises(DBAPIError): + await provision( + model_database, + BoundaryPorts(corrupt_admin_receipt=True), + limit=10_000, + ) + + +def execution(facts: DatabaseFacts) -> ModelCallExecutionFacts: + return ModelCallExecutionFacts( + project_id=facts.project_id, + task_id=facts.task_id, + assignment_id=facts.assignment_id, + submission_id=facts.submission_id, + submission_version=1, + evaluation_request_id=new_record_id(), + evaluation_generation=1, + checker_run_id=new_record_id(), + worker_lease_id=new_record_id(), + worker_lease_generation=1, + worker_lease_expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + + +def slot(facts: DatabaseFacts, profile, requirement: str) -> ModelPaidSlotKey: + return ModelPaidSlotKey( + project_id=facts.project_id, + assignment_id=facts.assignment_id, + submission_id=facts.submission_id, + submission_version=1, + requirement_id=requirement, + registry_entry_digest=facts.registry_entry_digest, + configuration_sha256=sha("configuration"), + profile_id=profile.profile_id, + profile_version=profile.spec.profile_version, + profile_digest=profile.profile_digest, + ) + + +def evidence(label: str, content_sha256: str) -> ModelCallEvidence: + return ModelCallEvidence( + binding_id=new_record_id(), + content_id=new_record_id(), + put_attempt_id=new_record_id(), + verification_receipt_id=new_record_id(), + content_sha256=content_sha256, + byte_count=100, + ) + + +async def prepare_and_reserve( + facts: DatabaseFacts, + ports: BoundaryPorts, + profile, + requirement: str, +): + run = execution(facts) + paid_slot = slot(facts, profile, requirement) + request_sha = sha(f"request-{requirement}") + request_evidence = evidence(f"request-{requirement}", request_sha) + async with AsyncSession(facts.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + prepared = await owner.prepare_attempt( + run, + paid_slot, + registry_entry_id=facts.registry_entry_id, + request_sha256=request_sha, + ) + reservation = await owner.reserve_dispatch(run, prepared, request_evidence) + return run, paid_slot, prepared, reservation, request_evidence + + +async def test_concurrent_dispatch_reserves_one_lifetime_budget( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation) + prepared = [] + for requirement in ("model_a", "model_b"): + run = execution(model_database) + paid_slot = slot(model_database, profile, requirement) + request_sha = sha(requirement) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + item = await service(session, ports).prepare_attempt( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=request_sha, + ) + prepared.append((run, item, evidence(requirement, request_sha))) + + async def dispatch(item): + run, attempt, request_evidence = item + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await service(session, ports).reserve_dispatch( + run, attempt, request_evidence + ) + + results = await asyncio.gather(*(dispatch(item) for item in prepared), return_exceptions=True) + assert sum(not isinstance(result, BaseException) for result in results) == 1 + assert sum(isinstance(result, ModelCallBudgetUnavailable) for result in results) == 1 + async with AsyncSession(model_database.engine) as session: + account = await session.get(CheckerModelBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == reservation + assert account.lifetime_settled_nanodollars == 0 + + +async def test_fresh_execution_authority_precedes_currentness_and_owner_locks( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation * 2) + run = execution(model_database) + paid_slot = slot(model_database, profile, "model_order") + request_sha = sha("ordered-request") + ports.events.clear() + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + prepared = await owner.prepare_attempt( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=request_sha, + ) + assert ports.events == ["prepare", "current"] + ports.events.clear() + dispatched = await owner.reserve_dispatch( + run, + prepared, + evidence("ordered-request", request_sha), + ) + assert ports.events == ["dispatch", "current"] + await owner.mark_uncertain( + dispatched.effect, + sha("ordered-uncertain"), + provider_response_id="resp_ordered", + ) + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await session.execute( + text("insert into checker_runs(id) values (:id)"), + {"id": run.checker_run_id}, + ) + owner = service(session, ports) + ports.events.clear() + await owner.attach_recovery(run, dispatched.effect) + assert ports.events == ["recover", "current"] + ports.events.clear() + with pytest.raises(ModelCallConflict, match="not_retryable"): + await owner.prepare_recovery_child( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + parent_attempt_id=prepared.attempt_id, + terminal_digest=sha("ordered-uncertain"), + request_sha256=prepared.request_sha256, + ) + assert ports.events == ["child", "current"] + + +async def test_reservation_rollback_and_completion_settle_exactly_once( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation * 2) + run = execution(model_database) + paid_slot = slot(model_database, profile, "model_rollback") + request_sha = sha("rollback-request") + request_art = evidence("request", request_sha) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + prepared = await service(session, ports).prepare_attempt( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=request_sha, + ) + with pytest.raises(RuntimeError, match="force rollback"): + async with session.begin(): + await service(session, ports).reserve_dispatch(run, prepared, request_art) + raise RuntimeError("force rollback") + async with session.begin(): + dispatched = await service(session, ports).reserve_dispatch(run, prepared, request_art) + + usage = ModelCallUsage( + input_tokens=40, + cached_input_tokens=10, + output_tokens=12, + reasoning_tokens=7, + ) + response_art = evidence("response", sha("response")) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + completed = await owner.complete( + dispatched.effect, + provider_response_id="resp_exact_once", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response_art, + usage=usage, + ) + replay = await owner.complete( + dispatched.effect, + provider_response_id="resp_exact_once", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response_art, + usage=usage, + ) + account = await session.get(CheckerModelBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == completed.settled_nanodollars + assert replay.replayed + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await session.execute( + text("insert into checker_runs(id) values (:id)"), + {"id": run.checker_run_id}, + ) + ports.events.clear() + attached = await service(session, ports).attach_completed(run, dispatched.effect) + assert attached.replayed is False + assert ports.events == ["recover", "current"] + + +async def test_uncertain_holds_budget_and_only_proven_no_effect_allows_child( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation * 2) + run, paid_slot, prepared, dispatched, _ = await prepare_and_reserve( + model_database, ports, profile, "model_recovery" + ) + uncertainty = sha("uncertain-delivery") + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + with pytest.raises(ModelCallUnavailable, match="model_call_effect_unavailable"): + await owner.mark_uncertain( + dispatched.effect.model_copy(update={"task_id": new_record_id()}), + uncertainty, + provider_response_id="resp_recoverable", + ) + observed = await owner.mark_uncertain( + dispatched.effect, + uncertainty, + provider_response_id="resp_recoverable", + ) + assert observed.state == "uncertain" + with pytest.raises(ModelCallConflict, match="not_retryable"): + await owner.prepare_recovery_child( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + parent_attempt_id=prepared.attempt_id, + terminal_digest=uncertainty, + request_sha256=prepared.request_sha256, + ) + account = await session.get(CheckerModelBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == reservation + + # A distinct call proves the only zero-cost child path: authenticated no effect. + run2, paid_slot2, prepared2, dispatched2, _ = await prepare_and_reserve( + model_database, ports, profile, "model_no_effect" + ) + proof = KnownNoEffectProof( + proof_id=new_record_id(), + proof_digest=sha("provider-no-effect"), + observed_at=datetime.now(UTC), + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + await owner.mark_known_no_effect(dispatched2.effect, proof) + child = await owner.prepare_recovery_child( + run2, + paid_slot2, + registry_entry_id=model_database.registry_entry_id, + parent_attempt_id=prepared2.attempt_id, + terminal_digest=model_retry_terminal_digest(proof), + request_sha256=prepared2.request_sha256, + ) + assert child.attempt_number == 2 + assert child.state == "prepared" + account = await session.get(CheckerModelBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == reservation + + +async def test_budget_adjustment_preserves_reserved_and_settles_old_snapshot( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation * 2) + run, paid_slot, prepared, dispatched, _ = await prepare_and_reserve( + model_database, ports, profile, "model_budget_adjustment" + ) + adjusted_spec = ProjectModelBudgetConfigurationSpec( + project_id=model_database.project_id, + configuration_version=2, + lifetime_limit_nanodollars=reservation * 3, + ) + adjusted_facts = ModelBudgetProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=adjusted_spec, + configuration_digest=canonical_json_hash(adjusted_spec.model_dump(mode="json")), + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + adjusted = await service(session, ports).provision_budget(adjusted_facts) + assert adjusted.lifetime_reserved_nanodollars == reservation + assert adjusted.lifetime_settled_nanodollars == 0 + + too_low_spec = ProjectModelBudgetConfigurationSpec( + project_id=model_database.project_id, + configuration_version=3, + lifetime_limit_nanodollars=reservation - 1, + ) + too_low_facts = ModelBudgetProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=too_low_spec, + configuration_digest=canonical_json_hash(too_low_spec.model_dump(mode="json")), + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + with pytest.raises(ModelCallBudgetUnavailable, match="below_lifetime_usage"): + async with session.begin(): + await service(session, ports).provision_budget(too_low_facts) + + usage = ModelCallUsage( + input_tokens=31, + cached_input_tokens=7, + output_tokens=11, + reasoning_tokens=4, + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + completed = await service(session, ports).complete( + dispatched.effect, + provider_response_id="resp_after_budget_adjustment", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=evidence("response", sha("budget-adjustment-response")), + usage=usage, + ) + account = await session.get(CheckerModelBudgetAccount, str(model_database.project_id)) + assert account.current_configuration_id == str(adjusted.configuration_id) + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == completed.settled_nanodollars + + +async def test_known_failed_settles_once_and_allows_authorized_child( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation * 2) + run, paid_slot, prepared, dispatched, _ = await prepare_and_reserve( + model_database, ports, profile, "model_known_failure" + ) + usage = ModelCallUsage( + input_tokens=22, + cached_input_tokens=2, + output_tokens=3, + reasoning_tokens=1, + ) + response_art = evidence("failed-response", sha("failed-response")) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + failed = await owner.complete_known_failure( + dispatched.effect, + provider_response_id="resp_terminal_failure", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response_art, + usage=usage, + ) + replay = await owner.complete_known_failure( + dispatched.effect, + provider_response_id="resp_terminal_failure", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response_art, + usage=usage, + ) + assert replay.replayed + child = await owner.prepare_recovery_child( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + parent_attempt_id=prepared.attempt_id, + terminal_digest=model_retry_terminal_digest(failed), + request_sha256=prepared.request_sha256, + ) + assert child.attempt_number == 2 + account = await session.get(CheckerModelBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == failed.settled_nanodollars + + +async def test_database_guards_reject_ledger_mutation_and_counter_substitution( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation * 2) + _, _, _, dispatched, _ = await prepare_and_reserve( + model_database, ports, profile, "model_guarded" + ) + + async with AsyncSession(model_database.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + await session.execute( + text( + "update checker_model_call_slots set first_request_sha256=:digest " + "where id=:slot_id" + ), + {"digest": sha("substituted"), "slot_id": dispatched.slot_id}, + ) + + async with AsyncSession(model_database.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + await session.execute( + text( + "update checker_model_budget_accounts " + "set lifetime_reserved_nanodollars=lifetime_reserved_nanodollars-1 " + "where project_id=:project_id" + ), + {"project_id": model_database.project_id}, + ) + + async with AsyncSession(model_database.engine) as session: + account = await session.get(CheckerModelBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == reservation + assert account.lifetime_settled_nanodollars == 0 + + +async def test_database_ancestry_prevents_receipt_and_artifact_deletion( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + profile = await provision(model_database, ports, limit=10_000) + _, _, _, _, request = await prepare_and_reserve( + model_database, ports, profile, "model_ancestry" + ) + for statement, identifier in ( + ("delete from actor_profiles where id=:id", profile.actor_profile_id), + ("delete from audit_events where id=:id", profile.authorization_evidence_id), + ("delete from artifact_bindings where id=:id", request.binding_id), + ("delete from artifact_contents where id=:id", request.content_id), + ("delete from artifact_put_attempts where id=:id", request.put_attempt_id), + ( + "delete from artifact_verification_receipts where id=:id", + request.verification_receipt_id, + ), + ): + async with AsyncSession(model_database.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + await session.execute(text(statement), {"id": identifier}) + + +async def test_profile_reader_requires_exact_project_registry_and_selector( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + profile = await provision(model_database, ports, limit=10_000) + async with AsyncSession(model_database.engine) as session: + async with session.begin(): + reader = ModelProfileReader(session) + assert ( + await reader.read_exact( + profile.selector, + project_id=model_database.project_id, + registry_entry_id=model_database.registry_entry_id, + registry_entry_digest=model_database.registry_entry_digest, + ) + == profile + ) + for changes in ( + {"project_id": new_record_id()}, + {"registry_entry_id": new_record_id()}, + {"registry_entry_digest": sha("substituted-registry")}, + ): + scope = { + "project_id": model_database.project_id, + "registry_entry_id": model_database.registry_entry_id, + "registry_entry_digest": model_database.registry_entry_digest, + } + scope.update(changes) + with pytest.raises(ModelCallUnavailable, match="model_profile_unavailable"): + await reader.read_exact(profile.selector, **scope) diff --git a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py new file mode 100644 index 000000000..6ac2de908 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py @@ -0,0 +1,489 @@ +"""Real PostgreSQL proof for one-use checker model invocation grants.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +import json +from pathlib import Path +import runpy +from uuid import UUID + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.identifiers import new_record_id +from app.modules.checkers.api.model_calls import ( + ModelActionEvidence, + ModelCallExecutionFacts, + ModelPaidSlotKey, + model_action_digest, +) +from app.modules.checkers.model_calls.grants import ModelInvocationGrants +from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( + BoundaryPorts, + DatabaseFacts, + execution, + model_database as _ledger_database, + provision, + service, + sha, + slot, +) + + +ledger_database_fixture = _ledger_database + + +class _CaptureOperations: + def __init__(self) -> None: + self.statements: list[str] = [] + + def execute(self, statement: str) -> None: + self.statements.append(statement) + + +@dataclass(frozen=True) +class GrantDatabase: + facts: DatabaseFacts + execution: ModelCallExecutionFacts + slot: ModelPaidSlotKey + prepared_attempt_id: UUID + prepared_request_sha256: str + + +class GrantBoundaries: + """Persist exact fake AUTH receipts while PostgreSQL owns all ancestry checks.""" + + def __init__( + self, + session: AsyncSession, + *, + evidence_id: UUID | None = None, + action: str = "checker.model_call.grant", + ) -> None: + self._session = session + self._evidence_id = evidence_id + self._action = action + + async def authorize_grant( + self, + execution_facts: ModelCallExecutionFacts, + paid_slot: ModelPaidSlotKey, + external_request_digest: str, + token_hash: str, + ) -> ModelActionEvidence: + facts = { + "execution": execution_facts.model_dump(mode="json"), + "slot": paid_slot.model_dump(mode="json"), + "external_request_digest": external_request_digest, + "token_hash": token_hash, + } + evidence = ModelActionEvidence( + evidence_id=self._evidence_id or new_record_id(), + facts_digest=model_action_digest("grant", facts), + ) + await self._session.execute( + text( + "insert into audit_events(id,event_domain,event_type,actor_ref_kind,actor_id," + "action_id,permission_id,denial_code,after_facts) values " + "(:id,'authority','SensitiveAuthorizationAllowed','actor_profile',:actor," + ":action,'checker.model_call.grant',null,cast(:after as jsonb)) " + "on conflict(id) do nothing" + ), + { + "id": evidence.evidence_id, + "actor": str(new_record_id()), + "action": self._action, + "after": json.dumps( + {"allowed": True, "resource_context_digest": sha("grant authority")} + ), + }, + ) + return evidence + + async def require_current( + self, + _: ModelCallExecutionFacts, + __: ModelPaidSlotKey, + ) -> None: + return None + + +def _isolated(statement: str, schema: str) -> str: + return statement.replace("public.", f'"{schema}".').replace( + "pg_catalog,public,pg_temp", f'pg_catalog,"{schema}",pg_temp' + ) + + +@pytest.fixture +async def grant_database(ledger_database_fixture: DatabaseFacts) -> GrantDatabase: + ledger_database = ledger_database_fixture + async with ledger_database.engine.connect() as connection: + schema = str(await connection.scalar(text("select current_schema()"))) + migration = runpy.run_path( + str(Path(__file__).resolve().parents[2] / "alembic/versions/0059_checker_model_runtime.py") + ) + capture = _CaptureOperations() + migration["_upgrade_invocation_grants"].__globals__["op"] = capture + migration["_upgrade_invocation_grants"]() + async with ledger_database.engine.begin() as connection: + await connection.execute( + text( + "alter table external_checker_registry_entries " + "add column entry_digest text, add column phase text" + ) + ) + await connection.execute( + text( + "update external_checker_registry_entries set entry_digest=:digest," + "phase='post_submit' where id=:id" + ), + { + "digest": ledger_database.registry_entry_digest, + "id": ledger_database.registry_entry_id, + }, + ) + await connection.execute( + text( + "alter table checker_runs add column project_id uuid, add column task_id uuid," + "add column submission_id uuid, add column submission_version integer," + "add column evaluation_request_id uuid, add column evaluation_generation integer," + "add column request_json text, add column worker_lease_id uuid," + "add column worker_lease_generation integer," + "add column worker_lease_expires_at timestamptz, add column status text," + "add column phase text, add column selected_post_submit_policy_sha256 text," + "add column selected_post_submit_policy_body jsonb," + "add column locked_post_submit_checker_policy_hash text" + ) + ) + for statement in capture.statements: + await connection.execute(text(_isolated(statement, schema))) + + profile = await provision(ledger_database, BoundaryPorts(), limit=100_000) + current = execution(ledger_database) + paid_slot = slot(ledger_database, profile, "model_judge") + request_sha256 = sha("prepared request") + async with AsyncSession(ledger_database.engine, expire_on_commit=False) as session: + async with session.begin(): + prepared = await service(session, BoundaryPorts()).prepare_attempt( + current, + paid_slot, + registry_entry_id=ledger_database.registry_entry_id, + request_sha256=request_sha256, + ) + + policy_sha256 = sha("locked post policy") + policy = { + "schema_version": "external_post_submit_policy.v1", + "entries": [ + { + "requirement_id": paid_slot.requirement_id, + "stage": "post_submit", + "registry": { + "registry_entry_id": str(ledger_database.registry_entry_id), + "entry_digest": paid_slot.registry_entry_digest, + }, + "configuration": { + "model_profile_id": str(paid_slot.profile_id), + "model_profile_version": paid_slot.profile_version, + "model_profile_digest": paid_slot.profile_digest, + }, + "configuration_sha256": paid_slot.configuration_sha256, + } + ], + "policy_sha256": policy_sha256, + } + async with ledger_database.engine.begin() as connection: + await connection.execute( + text( + "insert into checker_runs(id,project_id,task_id,submission_id,submission_version," + "evaluation_request_id,evaluation_generation,request_json,worker_lease_id," + "worker_lease_generation,worker_lease_expires_at,status,phase," + "selected_post_submit_policy_sha256,selected_post_submit_policy_body," + "locked_post_submit_checker_policy_hash) values " + "(:id,:project,:task,:submission,:version,:request,:generation,:request_json," + ":lease,:lease_generation,:expires,'running','post_submission',:policy_sha," + "cast(:policy as jsonb),:policy_sha)" + ), + { + "id": current.checker_run_id, + "project": current.project_id, + "task": current.task_id, + "submission": current.submission_id, + "version": current.submission_version, + "request": current.evaluation_request_id, + "generation": current.evaluation_generation, + "request_json": json.dumps({"assignment_id": str(current.assignment_id)}), + "lease": current.worker_lease_id, + "lease_generation": current.worker_lease_generation, + "expires": current.worker_lease_expires_at, + "policy_sha": policy_sha256, + "policy": json.dumps(policy), + }, + ) + return GrantDatabase( + ledger_database, + current, + paid_slot, + prepared.attempt_id, + request_sha256, + ) + + +async def _issue( + database: GrantDatabase, + *, + boundaries: GrantBoundaries, + session: AsyncSession, +): + return await ModelInvocationGrants( + session, + authority=boundaries, + current=boundaries, + ).issue( + database.execution, + database.slot, + sha("external checker request"), + registry_entry_id=database.facts.registry_entry_id, + ) + + +async def test_grant_round_trip_consumes_once_and_becomes_immutable( + grant_database: GrantDatabase, +) -> None: + async with AsyncSession(grant_database.facts.engine, expire_on_commit=False) as session: + async with session.begin(): + boundaries = GrantBoundaries(session) + grant = await _issue( + grant_database, + boundaries=boundaries, + session=session, + ) + owner = ModelInvocationGrants( + session, + authority=boundaries, + current=boundaries, + ) + facts = await owner.resolve(grant.grant_id, grant.binding_digest) + await owner.consume_prepared( + facts, + request_sha256=grant_database.prepared_request_sha256, + prepared_attempt_id=grant_database.prepared_attempt_id, + ) + row = ( + ( + await session.execute( + text( + "select selected_request_sha256,selected_attempt_id,consumed_at," + "issued_at,expires_at,token_hash from " + "checker_model_invocation_grants" + ) + ) + ) + .mappings() + .one() + ) + assert row.selected_request_sha256 == grant_database.prepared_request_sha256 + assert row.selected_attempt_id == grant_database.prepared_attempt_id + assert row.issued_at < row.consumed_at < row.expires_at + assert row.token_hash != grant.grant_id + + for mutation in ( + "update checker_model_invocation_grants set external_request_digest=" + "'sha256:" + "d" * 64 + "'", + "delete from checker_model_invocation_grants", + "truncate checker_model_invocation_grants", + ): + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + await session.execute(text(mutation)) + + +async def test_grant_rejects_reused_or_wrong_authority_and_stale_db_clock( + grant_database: GrantDatabase, +) -> None: + evidence_id = new_record_id() + async with AsyncSession(grant_database.facts.engine) as session: + async with session.begin(): + boundaries = GrantBoundaries(session, evidence_id=evidence_id) + await _issue(grant_database, boundaries=boundaries, session=session) + + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session, evidence_id=evidence_id) + await _issue(grant_database, boundaries=boundaries, session=session) + + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session, action="checker.model_call.dispatch") + await _issue(grant_database, boundaries=boundaries, session=session) + + expired = grant_database.execution.model_copy( + update={"worker_lease_expires_at": datetime.now(UTC) - timedelta(seconds=1)} + ) + async with grant_database.facts.engine.begin() as connection: + await connection.execute( + text("update checker_runs set worker_lease_expires_at=:expires where id=:id"), + {"expires": expired.worker_lease_expires_at, "id": expired.checker_run_id}, + ) + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session) + await ModelInvocationGrants( + session, + authority=boundaries, + current=boundaries, + ).issue( + expired, + grant_database.slot, + sha("expired request"), + registry_entry_id=grant_database.facts.registry_entry_id, + ) + + +async def test_grant_rejects_locked_binding_and_consumption_substitution( + grant_database: GrantDatabase, +) -> None: + async with AsyncSession(grant_database.facts.engine, expire_on_commit=False) as session: + async with session.begin(): + boundaries = GrantBoundaries(session) + grant = await _issue(grant_database, boundaries=boundaries, session=session) + facts = await ModelInvocationGrants( + session, + authority=boundaries, + current=boundaries, + ).resolve(grant.grant_id, grant.binding_digest) + + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + await session.execute( + text( + "update checker_model_invocation_grants " + "set external_request_digest=:request" + ), + {"request": sha("immutable grant facts")}, + ) + + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + await session.execute( + text( + "update checker_model_invocation_grants set " + "selected_request_sha256=:request,selected_attempt_id=:attempt," + "consumed_at=clock_timestamp()" + ), + { + "request": sha("substituted request"), + "attempt": grant_database.prepared_attempt_id, + }, + ) + + other_slot = grant_database.slot.model_copy(update={"requirement_id": "other_judge"}) + other_request = sha("other prepared request") + async with AsyncSession(grant_database.facts.engine, expire_on_commit=False) as session: + async with session.begin(): + other_attempt = await service(session, BoundaryPorts()).prepare_attempt( + grant_database.execution, + other_slot, + registry_entry_id=grant_database.facts.registry_entry_id, + request_sha256=other_request, + ) + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + await session.execute( + text( + "update checker_model_invocation_grants set " + "selected_request_sha256=:request,selected_attempt_id=:attempt," + "consumed_at=clock_timestamp()" + ), + {"request": other_request, "attempt": other_attempt.attempt_id}, + ) + + foreign_registry = new_record_id() + async with grant_database.facts.engine.begin() as connection: + await connection.execute( + text( + "insert into external_checker_registry_entries(id,entry_digest,phase) " + "values (:id,:digest,'post_submit')" + ), + {"id": foreign_registry, "digest": grant_database.slot.registry_entry_digest}, + ) + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session) + await ModelInvocationGrants( + session, + authority=boundaries, + current=boundaries, + ).issue( + grant_database.execution, + grant_database.slot, + sha("foreign registry request"), + registry_entry_id=foreign_registry, + ) + + foreign_project = new_record_id() + async with grant_database.facts.engine.begin() as connection: + await connection.execute( + text("insert into projects(id) values (:id)"), + {"id": foreign_project}, + ) + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session) + await ModelInvocationGrants( + session, + authority=boundaries, + current=boundaries, + ).issue( + grant_database.execution.model_copy(update={"project_id": foreign_project}), + grant_database.slot.model_copy(update={"project_id": foreign_project}), + sha("foreign project request"), + registry_entry_id=grant_database.facts.registry_entry_id, + ) + + async with grant_database.facts.engine.begin() as connection: + await connection.execute( + text( + "update checker_runs set selected_post_submit_policy_body=" + "jsonb_set(selected_post_submit_policy_body," + "'{entries,0,configuration_sha256}',to_jsonb(cast(:digest as text)))" + ), + {"digest": sha("substituted configuration")}, + ) + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session) + await _issue(grant_database, boundaries=boundaries, session=session) + + assert facts.slot == grant_database.slot + + +async def test_grant_trigger_locks_authority_then_task_then_checker_ancestry( + grant_database: GrantDatabase, +) -> None: + async with grant_database.facts.engine.connect() as connection: + definition = await connection.scalar( + text( + "select pg_get_functiondef(p.oid) from pg_proc p join pg_namespace n " + "on n.oid=p.pronamespace where p.proname=" + "'guard_checker_model_invocation_grant' and n.nspname=current_schema()" + ) + ) + insert_branch = definition.split("IF jsonb_typeof", maxsplit=1)[1] + assert insert_branch.index("audit_events") < insert_branch.index("workstream_tasks") + assert insert_branch.index("workstream_tasks") < insert_branch.index("checker_runs") + assert insert_branch.index("checker_runs") < insert_branch.index("checker_model_profiles") diff --git a/backend/tests/checkers_model_calls/test_model_runtime.py b/backend/tests/checkers_model_calls/test_model_runtime.py new file mode 100644 index 000000000..96c9e64b7 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_model_runtime.py @@ -0,0 +1,82 @@ +"""Configured proxy startup and provider ownership boundaries.""" + +from pathlib import Path +from types import SimpleNamespace + +import pytest +from pydantic import ValidationError + +from app.adapters.checkers import model_runtime +from app.core.config import Settings +from app.modules.checkers.api.model_calls import ModelCallUnavailable +from tests.artifact_store_helpers import artifact_admission_limit_settings +from tests.checkers_model_calls.test_proxy_orchestration import Harness + + +async def test_disabled_proxy_needs_no_database_storage_or_provider(monkeypatch): + def forbidden(*_args, **_kwargs): + raise AssertionError("disabled model proxy constructed a runtime") + + monkeypatch.setattr(model_runtime, "create_artifact_store_bootstrap", forbidden) + async with model_runtime.configured_model_proxy_runtime( + SimpleNamespace(checker_model_proxy_socket=None), + None, + ): + pass + + +def test_proxy_requires_absolute_socket_and_enabled_storage(tmp_path): + assert ( + Settings(checker_model_proxy_socket="", _env_file=None).checker_model_proxy_socket is None + ) + with pytest.raises(ValidationError, match="must be absolute"): + Settings(checker_model_proxy_socket=Path("relative.sock"), _env_file=None) + with pytest.raises(ValidationError, match="requires enabled artifact"): + Settings(checker_model_proxy_socket=tmp_path / "proxy.sock", _env_file=None) + settings = Settings( + environment="test", + artifact_store_backend="local", + artifact_local_root=tmp_path / "objects", + artifact_scratch_root=tmp_path / "scratch", + checker_model_proxy_socket=tmp_path / "sockets/proxy.sock", + **artifact_admission_limit_settings(), + _env_file=None, + ) + assert settings.checker_model_proxy_socket == tmp_path / "sockets/proxy.sock" + + +async def test_provider_uses_profile_bounds_and_closes_owned_client(monkeypatch): + profile = Harness().profile + captured = [] + closed = [] + + async def close(): + closed.append(True) + + def construct(settings): + captured.append(settings) + return SimpleNamespace(aclose=close) + + monkeypatch.setenv("OPENAI_API_KEY", "private-test-key") + monkeypatch.setattr(model_runtime, "create_checker_model_provider", construct) + async with model_runtime.configured_model_provider(profile): + assert len(captured) == 1 + settings = captured[0] + assert settings.model == profile.spec.model_snapshot + assert settings.maximum_input_tokens == profile.spec.maximum_input_tokens + assert settings.maximum_output_tokens == profile.spec.maximum_output_tokens + assert settings.reasoning_effort == profile.spec.settings.reasoning_effort + assert "private-test-key" not in repr(settings) + assert closed == [True] + + +async def test_missing_provider_key_stops_before_reserving_budget(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + h = Harness() + h.provider = model_runtime.configured_model_provider + assert (await h.invoke()).outcome == "unavailable" + assert h.state == "prepared" + assert "dispatch_committed" not in h.events + with pytest.raises(ModelCallUnavailable): + async with model_runtime.configured_model_provider(h.profile): + pass diff --git a/backend/tests/checkers_model_calls/test_proxy_orchestration.py b/backend/tests/checkers_model_calls/test_proxy_orchestration.py new file mode 100644 index 000000000..4a80850f6 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_proxy_orchestration.py @@ -0,0 +1,334 @@ +"""Cross-port call sequencing; PostgreSQL/ART custody have separate integration proof.""" + +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta +import hashlib +import json +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.interfaces.checker_model_provider import ( + CheckerModelCreateFailure, + CheckerModelCreateOutcome, + CheckerModelCreateResult, + CheckerModelObservation, + CheckerModelProviderStatus, + CheckerModelRecoveryOutcome, + CheckerModelRecoveryResult, + CheckerModelResponseReference, + CheckerModelUsage, +) +from app.interfaces.checker_model_proxy import ModelProxyRequest +from app.modules.checkers.api.model_calls import ( + AttachedModelCallAttempt, + CompletedModelCall, + ModelCallBudgetUnavailable, + ModelCallEvidence, + ModelCallExecutionFacts, + ModelCallUnavailable, + ModelPaidSlotKey, + ProjectModelProfile, +) +from app.modules.checkers.model_calls.proxy import CheckerModelProxy +from tests.checkers_model_calls.test_model_call_contract import profile_spec + + +def digest(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +class Harness: + """Retain port-observable effects across fresh proxy instances and invocations.""" + + def __init__(self): + spec = profile_spec() + self.profile = ProjectModelProfile( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=spec, + profile_digest=canonical_json_hash(spec.model_dump(mode="json")), + profile_id=new_record_id(), + authorization_evidence_id=new_record_id(), + created_at=datetime.now(UTC), + ) + self.execution = ModelCallExecutionFacts( + project_id=spec.project_id, + task_id=new_record_id(), + assignment_id=new_record_id(), + submission_id=new_record_id(), + submission_version=1, + evaluation_request_id=new_record_id(), + evaluation_generation=1, + checker_run_id=new_record_id(), + worker_lease_id=uuid4(), + worker_lease_generation=1, + worker_lease_expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + self.slot = ModelPaidSlotKey( + project_id=spec.project_id, + assignment_id=self.execution.assignment_id, + submission_id=self.execution.submission_id, + submission_version=1, + requirement_id="quality", + registry_entry_digest=spec.registry_entry_digest, + configuration_sha256=canonical_json_hash({"selector": "quality"}), + **self.profile.selector.model_dump(), + ) + self.facts = SimpleNamespace( + execution=self.execution, + slot=self.slot, + registry_entry_id=spec.registry_entry_id, + ) + self.state, self.request_sha = "prepared", None + self.slot_id, self.attempt_id, self.internal_id = (new_record_id() for _ in range(3)) + self.reference = CheckerModelResponseReference("resp_original") + self.observation = CheckerModelObservation( + reference=self.reference, + provider_model=spec.model_snapshot, + status=CheckerModelProviderStatus.COMPLETED, + output_text='{"verdict":"pass"}', + usage=CheckerModelUsage(20, 5, 10, 2, 30), + ) + self.create_result = CheckerModelCreateResult( + CheckerModelCreateOutcome.ACCEPTED, + observation=self.observation, + known_response=self.reference, + ) + self.active_transactions = 0 + self.events, self.blobs, self.evidence = [], {}, {} + self.create_count, self.recover_count, self.settle_count = 0, 0, 0 + self.budget_available = True + self.provider_id = None + self.completed = None + self.stale = False + + @asynccontextmanager + async def sessions(self): + yield self + + @asynccontextmanager + async def begin(self): + self.active_transactions += 1 + try: + yield self + finally: + self.active_transactions -= 1 + + async def scalar(self, _): + return datetime.now(UTC) + + async def authorization(self, *_): + return object() + + async def resolve(self, *_): + return self.facts + + async def read_exact(self, *_args, **_kwargs): + return self.profile + + async def prepare_attempt(self, _execution, _slot, *, request_sha256, **_): + if self.request_sha not in {None, request_sha256}: + raise ModelCallUnavailable("changed_prompt") + self.request_sha = request_sha256 + return AttachedModelCallAttempt( + slot_id=self.slot_id, + slot_key=self.slot, + attempt_id=self.attempt_id, + attempt_number=1, + internal_call_request_id=self.internal_id, + request_sha256=request_sha256, + state=self.state, + replayed=self.state != "prepared", + ) + + async def consume_prepared(self, *_args, **_kwargs): + self.events.append("grant_consumed") + + async def reserve_dispatch(self, _execution, _prepared, evidence): + if not self.budget_available: + raise ModelCallBudgetUnavailable("no_budget") + self.events.append("dispatch_committed") + self.state = "dispatching" + return SimpleNamespace(state=self.state, replayed=False) + + async def mark_uncertain(self, _effect, _digest, *, provider_response_id): + self.provider_id = provider_response_id + self.state = "uncertain" + self.events.append("reference_committed") + + async def mark_known_no_effect(self, *_): + self.state = "known_no_effect" + + async def attach_recovery(self, *_): + if self.provider_id is None: + raise ModelCallUnavailable("unknown_effect") + return SimpleNamespace(provider_response_id=self.provider_id) + + async def complete(self, _effect, *, response_evidence, usage, **_): + self.settle_count += 1 + self.state = "completed" + self.completed = CompletedModelCall( + slot_id=self.slot_id, + attempt_id=self.attempt_id, + attempt_number=1, + internal_call_request_id=self.internal_id, + provider_response_id=self.provider_id, + request_evidence=self.evidence["model_request"], + response_evidence=response_evidence, + usage=usage, + settled_nanodollars=465, + completed_at=datetime.now(UTC), + replayed=False, + ) + + async def complete_known_failure(self, *args, **kwargs): + await self.complete(*args, **kwargs) + self.state = "known_failed" + + async def attach_completed(self, *_): + if self.stale: + raise ModelCallUnavailable("stale_invocation") + return self.completed + + @asynccontextmanager + async def provider(self, _): + yield self + + async def create(self, _): + assert self.active_transactions == 0 + assert "dispatch_committed" in self.events + self.create_count += 1 + return self.create_result + + async def recover(self, reference): + assert self.active_transactions == 0 + assert reference == self.reference + self.recover_count += 1 + return CheckerModelRecoveryResult( + CheckerModelRecoveryOutcome.OBSERVED, + self.reference, + self.observation, + ) + + async def store(self, request): + assert self.active_transactions == 0 + data = b"".join([chunk async for chunk in request.byte_source]) + assert digest(data) == request.selector.content_sha256 + role = request.selector.slot_key + if role == "model_response": + assert self.provider_id is not None + self.blobs[role] = data + evidence = self.evidence.setdefault( + role, + ModelCallEvidence( + binding_id=new_record_id(), + content_id=new_record_id(), + put_attempt_id=new_record_id(), + verification_receipt_id=new_record_id(), + content_sha256=digest(data), + byte_count=len(data), + ), + ) + return SimpleNamespace(status="verified", **evidence.model_dump()) + + async def bind_checker_output(self, request): + return self.evidence[request.selector.slot_key] + + async def read_response(self, _selector, evidence): + assert evidence == self.evidence["model_response"] + return self.blobs["model_response"] + + def proxy(self): + return CheckerModelProxy( + sessions=self.sessions, + authorization=self.authorization, + grants=lambda *_: self, + calls=lambda *_: self, + profiles=lambda *_: self, + outputs=self, + bindings=lambda *_: self, + provider=self.provider, + ) + + async def invoke(self, prompt="Evaluate this work"): + return await self.proxy().handle( + ModelProxyRequest( + "model_" + uuid4().hex, + canonical_json_hash({"request": 1}), + prompt, + ) + ) + + +async def test_completed_effect_is_reused_across_proxy_restart(): + h = Harness() + first = await h.invoke() + second = await h.invoke() + assert first == second + assert first.outcome == "completed" + assert (h.create_count, h.recover_count, h.settle_count) == (1, 0, 1) + assert json.loads(h.blobs["model_request"])["prompt"] == "Evaluate this work" + + +@pytest.mark.parametrize("known_id", [True, False]) +async def test_lost_create_response_never_creates_again(known_id): + h = Harness() + h.create_result = CheckerModelCreateResult( + CheckerModelCreateOutcome.UNCERTAIN, + known_response=h.reference if known_id else None, + failure=CheckerModelCreateFailure.DELIVERY_UNCERTAIN, + ) + assert (await h.invoke()).outcome == "unavailable" + assert h.state == "uncertain" + retried = await h.invoke() + assert retried.outcome == ("completed" if known_id else "unavailable") + assert h.create_count == 1 + assert h.recover_count == int(known_id) + assert h.settle_count == int(known_id) + + +async def test_budget_denial_never_reaches_provider(): + h = Harness() + h.budget_available = False + assert (await h.invoke()).outcome == "unavailable" + assert h.create_count == h.recover_count == h.settle_count == 0 + assert h.state == "prepared" + + +async def test_changed_prompt_is_not_a_new_paid_call(): + h = Harness() + assert (await h.invoke()).outcome == "completed" + assert (await h.invoke("Different work")).outcome == "unavailable" + assert h.create_count == h.settle_count == 1 + + +async def test_missing_usage_retains_uncertain_effect_for_later_recovery(): + h = Harness() + h.create_result = CheckerModelCreateResult( + CheckerModelCreateOutcome.ACCEPTED, + known_response=h.reference, + observation=CheckerModelObservation( + reference=h.reference, + provider_model=h.profile.spec.model_snapshot, + status=CheckerModelProviderStatus.FAILED, + ), + ) + assert (await h.invoke()).outcome == "unavailable" + assert h.state == "uncertain" and h.settle_count == 0 + assert "model_response" not in h.blobs + assert (await h.invoke()).outcome == "completed" + assert h.create_count == h.settle_count == 1 + + +async def test_stale_invocation_cannot_read_a_completed_effect(): + h = Harness() + h.stale = True + assert (await h.invoke()).outcome == "unavailable" + assert h.state == "completed" and h.settle_count == 1 + h.stale = False + assert (await h.invoke()).outcome == "completed" + assert h.create_count == 1 diff --git a/backend/tests/checkers_model_calls/test_public_api.py b/backend/tests/checkers_model_calls/test_public_api.py new file mode 100644 index 000000000..29d004d12 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_public_api.py @@ -0,0 +1,170 @@ +"""Public Operator contract for checker model profile, budget and recovery controls.""" + +from datetime import UTC, datetime, timedelta + +import pytest + +from app.api.deps.checker_models import CheckerModelAdminCommands +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.main import create_app +from app.modules.checkers.api.model_calls import ( + ModelCallExecutionFacts, + ModelCallConflict, + ModelGenerationSettings, + ModelPaidSlotKey, + ModelPricing, + ModelRecoveryChildRequest, + ProjectModelBudgetConfigurationSpec, + ProjectModelProfileSpec, +) + + +def sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +def profile_spec(project_id): + return ProjectModelProfileSpec( + project_id=project_id, + profile_version=1, + registry_entry_id=new_record_id(), + registry_entry_digest=sha("registry"), + provider="openai_responses", + api="responses.v1", + model_snapshot="gpt-example-2026-10-01", + settings=ModelGenerationSettings(reasoning_effort="medium", text_verbosity="low"), + maximum_request_bytes=10_000, + maximum_output_bytes=4_000, + maximum_response_bytes=30_000, + maximum_input_tokens=100, + maximum_output_tokens=50, + deadline_ms=30_000, + pricing=ModelPricing( + pricing_policy_id=new_record_id(), + pricing_policy_version=1, + pricing_policy_digest=sha("pricing"), + uncached_input_nanodollars_per_token=11, + cached_input_nanodollars_per_token=2, + output_nanodollars_per_token=29, + ), + ) + + +def test_openapi_exposes_only_exact_operator_model_mutations() -> None: + paths = create_app().openapi()["paths"] + expected = { + "/api/v1/operations/projects/{project_id}/model-profiles": ( + "project.model_profile.provision", + "ProjectModelProfileSpec", + ), + "/api/v1/operations/projects/{project_id}/model-budget-configurations": ( + "project.model_budget.configure", + "ProjectModelBudgetConfigurationSpec", + ), + "/api/v1/operations/projects/{project_id}/model-call-attempts/" + "{parent_attempt_id}/recovery-children": ( + "project.model_call.recovery_child.authorize", + "ModelRecoveryChildRequest", + ), + } + for path, (action, schema_name) in expected.items(): + operation = paths[path]["post"] + assert operation["x-workstream-action-id"] == action + assert operation["requestBody"]["content"]["application/json"]["schema"] == { + "$ref": f"#/components/schemas/{schema_name}" + } + keys = [item for item in operation["parameters"] if item["name"] == "Idempotency-Key"] + assert len(keys) == 1 + assert keys[0]["required"] is True + + +@pytest.mark.asyncio +async def test_public_commands_bind_actor_key_project_and_exact_recovery_facts() -> None: + class Service: + calls = [] + + async def provision_profile(self, facts): + self.calls.append(("profile", facts)) + return facts + + async def provision_budget(self, facts): + self.calls.append(("budget", facts)) + return facts + + async def prepare_recovery_child(self, execution, slot, **values): + self.calls.append(("child", execution, slot, values)) + return values + + project_id = new_record_id() + actor_id = new_record_id() + operation_id = new_record_id() + service = Service() + commands = CheckerModelAdminCommands(service, actor_id, operation_id) + profile = profile_spec(project_id) + await commands.provision_profile(project_id, profile) + budget = ProjectModelBudgetConfigurationSpec( + project_id=project_id, + configuration_version=1, + lifetime_limit_nanodollars=10_000_000_000, + ) + await commands.configure_budget(project_id, budget) + + execution = ModelCallExecutionFacts( + project_id=project_id, + task_id=new_record_id(), + assignment_id=new_record_id(), + submission_id=new_record_id(), + submission_version=1, + evaluation_request_id=new_record_id(), + evaluation_generation=1, + checker_run_id=new_record_id(), + worker_lease_id=new_record_id(), + worker_lease_generation=1, + worker_lease_expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + slot = ModelPaidSlotKey( + project_id=project_id, + assignment_id=execution.assignment_id, + submission_id=execution.submission_id, + submission_version=1, + requirement_id="model_review", + registry_entry_digest=profile.registry_entry_digest, + configuration_sha256=sha("configuration"), + profile_id=new_record_id(), + profile_version=1, + profile_digest=sha("profile"), + ) + request = ModelRecoveryChildRequest( + execution=execution, + slot=slot, + registry_entry_id=profile.registry_entry_id, + terminal_digest=sha("terminal"), + request_sha256=sha("request"), + ) + parent_id = new_record_id() + await commands.prepare_recovery_child(project_id, parent_id, request) + + profile_facts = service.calls[0][1] + assert profile_facts.operation_id == operation_id + assert profile_facts.actor_profile_id == actor_id + assert profile_facts.spec == profile + assert profile_facts.profile_digest == canonical_json_hash(profile.model_dump(mode="json")) + budget_facts = service.calls[1][1] + assert budget_facts.operation_id == operation_id + assert budget_facts.actor_profile_id == actor_id + assert budget_facts.spec == budget + assert service.calls[2] == ( + "child", + execution, + slot, + { + "registry_entry_id": profile.registry_entry_id, + "parent_attempt_id": parent_id, + "terminal_digest": request.terminal_digest, + "request_sha256": request.request_sha256, + }, + ) + + with pytest.raises(ModelCallConflict, match="project_conflict"): + await commands.provision_profile(new_record_id(), profile) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8d3129162..b1f3a9e3f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,7 +23,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "a6b6381144f041b1a1ab9aaee30731dc1a2eada85d030fd0ba8d6d6ccfdc5787" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "c06129a0f05f8062678f7d76016b2ec75cb457c088d54be07c7ad73924e976ee" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", @@ -56,6 +56,13 @@ "authority_control", "authority_idempotency_records", "checker_policies", + "checker_model_invocation_grants", + "checker_model_profiles", + "checker_model_budget_accounts", + "checker_model_budget_configurations", + "checker_model_call_slots", + "checker_model_call_attempts", + "checker_model_call_recoveries", "external_checker_registry_entries", "checker_retry_receipts", "checker_result_findings", @@ -176,6 +183,13 @@ "task_post_submit_routing_manifests", "task_post_submit_routing_requests", "checker_policies", + "checker_model_invocation_grants", + "checker_model_profiles", + "checker_model_budget_accounts", + "checker_model_budget_configurations", + "checker_model_call_slots", + "checker_model_call_attempts", + "checker_model_call_recoveries", "admin_role_grants", "audit_events", "authority_control", diff --git a/backend/tests/post_submit_materialization_helpers.py b/backend/tests/post_submit_materialization_helpers.py index aecb4b5b7..5a94319c5 100644 --- a/backend/tests/post_submit_materialization_helpers.py +++ b/backend/tests/post_submit_materialization_helpers.py @@ -248,6 +248,7 @@ async def _material_fixture( max_revision_rounds=2, pre_submit_registry_spec=None, post_submit_registry_spec=None, + post_submit_binding_setup=None, post_scratch_root=None, ): engine = create_async_engine(database_url) @@ -265,6 +266,7 @@ async def _material_fixture( max_revision_rounds=max_revision_rounds, pre_submit_registry_spec=pre_submit_registry_spec, post_submit_registry_spec=post_submit_registry_spec, + post_submit_binding_setup=post_submit_binding_setup, ) ( context, diff --git a/backend/tests/projects/guide_activation/source_fixtures.py b/backend/tests/projects/guide_activation/source_fixtures.py index b537b933d..a2383caa3 100644 --- a/backend/tests/projects/guide_activation/source_fixtures.py +++ b/backend/tests/projects/guide_activation/source_fixtures.py @@ -1,5 +1,6 @@ """Current guide creation and compilation prerequisites; no lifecycle guards disabled.""" +from collections.abc import Awaitable, Callable from contextlib import asynccontextmanager from uuid import UUID, uuid4 @@ -22,6 +23,8 @@ from app.modules.projects.schemas import ProjectGuideCreate from app.modules.projects.models import ProjectSetupRun from app.interfaces.project_agents import GuideEvidenceRef +from app.interfaces.project_agents import CapabilityParameter +from app.modules.checkers.api.external import ExternalCheckerRegistryEntry from tests.committed_guide_fixtures import ( create_committed_document_fixture, seed_setup_service_for_compiled_fixture, @@ -72,7 +75,21 @@ async def _create_guide_source(factory, values, actor, version): return setup.source_snapshot_id -def _external_compilation_outcome(outcome, registry_entries, pre_entry, post_entry, refs): +PostSubmitBindingSetup = Callable[ + [async_sessionmaker, UUID, ExternalCheckerRegistryEntry], + Awaitable[tuple[CapabilityParameter, ...]], +] + + +def _external_compilation_outcome( + outcome, + registry_entries, + pre_entry, + post_entry, + refs, + *, + post_submit_parameters=(), +): if not registry_entries: return outcome from app.interfaces.project_agents import ( @@ -123,7 +140,7 @@ def _external_compilation_outcome(outcome, registry_entries, pre_entry, post_ent capability_id=post_entry.capability_id, capability_version=post_entry.capability_version, stage="post_submit", - parameters=(), + parameters=post_submit_parameters, ) ) return outcome.model_copy( @@ -144,6 +161,7 @@ async def create_compiled_guide( artifact_proposal=None, pre_submit_registry_entry=None, post_submit_registry_entry=None, + post_submit_parameters=(), compilation_outcome=None, ): """Create declarations with real AUTH, then execute/project/finalize scripted findings.""" @@ -202,6 +220,7 @@ async def create_compiled_guide( pre_submit_registry_entry, post_submit_registry_entry, refs, + post_submit_parameters=post_submit_parameters, ) url = factory.kw["bind"].url.render_as_string(hide_password=False) finalization = await compilation_and_projections( @@ -224,6 +243,7 @@ async def source_case( artifact_proposal=None, pre_submit_registry_spec=None, post_submit_registry_spec=None, + post_submit_binding_setup: PostSubmitBindingSetup | None = None, compilation_outcome=None, ): """An authorized draft Project plus a genuinely created complete guide source.""" @@ -277,6 +297,11 @@ async def source_case( if post_submit_registry_spec is not None else None ) + post_submit_parameters = ( + await post_submit_binding_setup(factory, values["project"], post_registry_entry) + if post_registry_entry is not None and post_submit_binding_setup is not None + else () + ) values, finalization = await create_compiled_guide( factory, values, @@ -285,6 +310,7 @@ async def source_case( artifact_proposal=artifact_proposal, pre_submit_registry_entry=pre_registry_entry, post_submit_registry_entry=post_registry_entry, + post_submit_parameters=post_submit_parameters, compilation_outcome=compilation_outcome, ) yield values, factory, finalization, actor, grant diff --git a/backend/tests/projects/guide_compilation/test_check_specifications.py b/backend/tests/projects/guide_compilation/test_check_specifications.py index aa0c49b83..7d6208c8b 100644 --- a/backend/tests/projects/guide_compilation/test_check_specifications.py +++ b/backend/tests/projects/guide_compilation/test_check_specifications.py @@ -235,7 +235,7 @@ def test_model_evaluation_requires_post_even_with_a_short_duration(stage: str) - { "dependency_id": "model_judge", "kind": "model_service", - "provisioning": "runtime_gap", + "provisioning": "model_proxy", "purpose": "Evaluate the report against the rubric.", "unavailable_code": "implementation_unavailable", } @@ -274,6 +274,51 @@ def test_model_evaluation_requires_post_even_with_a_short_duration(stage: str) - require_complete_generated_specifications(compilation_context, proposal) +def test_new_generation_rejects_legacy_model_runtime_gap() -> None: + specification = _specification( + evaluation_method="model_evaluation", + execution_mode="background", + runtime_dependencies=[ + { + "dependency_id": "model_judge", + "kind": "model_service", + "provisioning": "runtime_gap", + "purpose": "Evaluate the report against the rubric.", + "unavailable_code": "implementation_unavailable", + } + ], + infrastructure_failures=[ + { + "code": "implementation_unavailable", + "condition": "The model service is unavailable.", + } + ], + ) + requirement = _requirement( + disposition="post_submit_capability_gap", + check_specification=specification.model_dump(mode="json"), + ) + proposal = _with_requirement(requirement).model_copy( + update={ + "status": "draft_ready_with_warnings", + "capability_suggestions": ( + CapabilitySuggestion( + requirement_id=requirement.requirement_id, + stage="post_submit", + title="Report evaluation", + rationale="The project image or exact model profile is unavailable.", + evidence_refs=requirement.evidence_refs, + ), + ), + } + ) + + # Immutable older results remain decodable, but cannot pass new generation admission. + ProjectGuideCompilationResult.model_validate(proposal.model_dump(mode="json")) + with pytest.raises(ValueError, match="model service requires model proxy"): + require_complete_generated_specifications(context(ids()), proposal) + + def test_pre_submit_binding_must_fit_the_stage_budget() -> None: entry = _entry_with_deadline(60_001) compilation_context = context(ids(), checker_capabilities=capability_projection(entry)) diff --git a/backend/tests/projects/unified_policy_fixtures.py b/backend/tests/projects/unified_policy_fixtures.py index 32935a494..cafa26b59 100644 --- a/backend/tests/projects/unified_policy_fixtures.py +++ b/backend/tests/projects/unified_policy_fixtures.py @@ -169,6 +169,7 @@ async def create_standalone_unified_policy( max_revision_rounds=2, pre_submit_registry_spec=None, post_submit_registry_spec=None, + post_submit_binding_setup=None, ): """Arrange complete activated context before a downstream artifact transaction.""" from app.modules.projects.models import PreSubmitCheckerPolicy @@ -188,6 +189,7 @@ async def create_standalone_unified_policy( artifact_proposal=artifact_proposal, pre_submit_registry_spec=pre_submit_registry_spec, post_submit_registry_spec=post_submit_registry_spec, + post_submit_binding_setup=post_submit_binding_setup, ) as (values, _, command, actor, grant): await seed_selected_review_revision_inputs( sessions, command, actor, human_review_required=human_review_required, diff --git a/backend/tests/selected_policy_fixtures.py b/backend/tests/selected_policy_fixtures.py index 74ab74b9c..b4cbe3773 100644 --- a/backend/tests/selected_policy_fixtures.py +++ b/backend/tests/selected_policy_fixtures.py @@ -16,6 +16,7 @@ async def approved_selected_policy_fixture( max_revision_rounds=2, pre_submit_registry_spec=None, post_submit_registry_spec=None, + post_submit_binding_setup=None, ): """Build an activated guide backed only by immutable external policy custody.""" from app.interfaces.project_agents import SubmissionArtifactPolicyProposal @@ -38,6 +39,7 @@ async def approved_selected_policy_fixture( ), pre_submit_registry_spec=pre_submit_registry_spec, post_submit_registry_spec=post_submit_registry_spec, + post_submit_binding_setup=post_submit_binding_setup, ) async with factory() as session: guide = await session.get(ProjectGuide, str(values["guide"])) diff --git a/backend/tests/test_agent_runtime.py b/backend/tests/test_agent_runtime.py index b399d2438..74c5c60cc 100644 --- a/backend/tests/test_agent_runtime.py +++ b/backend/tests/test_agent_runtime.py @@ -726,7 +726,8 @@ def test_default_instructions_advertise_contextual_output_constraints(): "Automation alone does not make a check POST", "Give every atomic requirement a classification_rationale", "evaluation_method isolated_execution requires an entry", - "model evaluation always belongs to POST background work", + "and always belongs to POST background work", + "Model access requires a registered project image plus its exact approved profile", "include its unavailable_code once in infrastructure_failures", "must appear in both inputs and runtime_dependencies, never only one", "Reading short prose does not make a judgment static", diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index efcd7f2da..a3abca526 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -136,6 +136,7 @@ def test_v01_graph_has_one_root_and_head() -> None: assert [revision.revision for revision in revisions] == [ HEAD_REVISION, + "0058_compensation_unit_create", "0057_roles_anywhere_custody", "0056_manager_authored_runtime", "0055_delivery_reconciliation", diff --git a/backend/tests/test_artifact_admission.py b/backend/tests/test_artifact_admission.py index ffac080d3..be1679dec 100644 --- a/backend/tests/test_artifact_admission.py +++ b/backend/tests/test_artifact_admission.py @@ -476,7 +476,7 @@ async def test_committed_put_and_independent_verification_are_fenced( async with minted_source( tmp_path / "fenced-checker-output", b"independently verified bytes", - media_type="text/plain", + media_type="application/json", ) as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) @@ -567,7 +567,7 @@ def open(self, _provider_object_ref, **_kwargs): try: async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / "namespace-fence", b"fenced") as source: + async with minted_source(tmp_path / "namespace-fence", b"fenced", media_type="application/json") as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) drifted = ArtifactStorageOrchestrator( @@ -1316,7 +1316,7 @@ async def test_verification_resource_drift_after_read_is_stale_without_terminal_ try: async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / "verification-drift", b"expected") as source: + async with minted_source(tmp_path / "verification-drift", b"expected", media_type="application/json") as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) orchestrator = ArtifactStorageOrchestrator( @@ -1393,7 +1393,7 @@ async def test_verification_rechecks_relationship_after_prepare_before_io( try: async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / "preclaim-drift", b"expected") as source: + async with minted_source(tmp_path / "preclaim-drift", b"expected", media_type="application/json") as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) allowing = ArtifactStorageOrchestrator( @@ -1486,7 +1486,7 @@ async def prepare(self, **values: object) -> None: attempts: list[ArtifactPutAttempt] = [] for name, value in (("first", b"first"), ("second", b"second")): relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / name, value) as source: + async with minted_source(tmp_path / name, value, media_type="application/json") as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships ) @@ -1544,7 +1544,7 @@ async def test_verification_rechecks_authorized_object_ref_before_io( try: async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / "preclaim-object-ref-drift", b"expected") as source: + async with minted_source(tmp_path / "preclaim-object-ref-drift", b"expected", media_type="application/json") as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) allowing = ArtifactStorageOrchestrator( @@ -1602,7 +1602,7 @@ async def test_verification_rechecks_authorized_object_ref_after_io( try: async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / "postread-object-ref-drift", b"expected") as source: + async with minted_source(tmp_path / "postread-object-ref-drift", b"expected", media_type="application/json") as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) orchestrator = ArtifactStorageOrchestrator( @@ -1664,7 +1664,7 @@ async def test_verification_terminal_result_matrix( try: async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / expected, b"verification matrix") as source: + async with minted_source(tmp_path / expected, b"verification matrix", media_type="application/json") as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) orchestrator = ArtifactStorageOrchestrator( @@ -1713,7 +1713,7 @@ async def test_verification_terminal_authority_denial_writes_zero_result_facts( async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) async with minted_source( - tmp_path / f"verify-{denial_reason}", denial_reason.encode() + tmp_path / f"verify-{denial_reason}", denial_reason.encode(), media_type="application/json" ) as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) @@ -2249,7 +2249,7 @@ async def test_checker_output_requires_exact_active_fixed_service_identity( baseline = await checker_admission_baseline(session) await session.rollback() - async with minted_source(tmp_path / "scratch-source", b"checker") as source: + async with minted_source(tmp_path / "scratch-source", b"checker", media_type="application/json") as source: service = ArtifactAdmissionService(session, settings, namespace) request = await _checker_output_request( session, checker_run_id, source @@ -2283,22 +2283,8 @@ async def test_checker_output_requires_exact_active_fixed_service_identity( replay = await service.admit( request, checker_output_authority=authority ) - takeover_request = replace( - request, - reservation=request.reservation.model_copy( - update={ - "worker_lease_id": new_record_id(), - "worker_lease_generation": 2, - } - ), - ) - takeover = await service.admit( - takeover_request, - checker_output_authority=authority, - ) - await assert_exact_checker_admission( - session, result=result, replay=replay, takeover=takeover, + session, result=result, replay=replay, request=request, authority=authority, project_id=project_id, task_id=task_id, checker_run_id=checker_run_id, baseline=baseline, ) @@ -2326,7 +2312,7 @@ async def test_invalid_checker_slot_precedes_namespace_drift( namespace, namespace_fingerprint="sha256:" + "f" * 64, ) - async with minted_source(tmp_path / "scratch-source", b"checker") as source: + async with minted_source(tmp_path / "scratch-source", b"checker", media_type="application/json") as source: valid = await _checker_output_request( session, checker_run_id, source ) @@ -2354,7 +2340,7 @@ async def _seed_verification_scan_jobs(factory, settings, namespace, store, tmp_ for index in range(3): relationships = await seed_checker_output_relationships(seed_session, namespace, policy_bundle=policy_bundle) async with minted_source( - tmp_path / f"verification-scan-{index}", f"job-{index}".encode() + tmp_path / f"verification-scan-{index}", f"job-{index}".encode(), media_type="application/json" ) as source: _, _, _, admission = await _admit_checker_output( seed_session, settings, namespace, source, relationships=relationships diff --git a/backend/tests/test_artifact_architecture.py b/backend/tests/test_artifact_architecture.py index 9b9f00857..0bdaad38e 100644 --- a/backend/tests/test_artifact_architecture.py +++ b/backend/tests/test_artifact_architecture.py @@ -631,7 +631,7 @@ def test_checker_output_contract_has_one_canonical_consumer_owner() -> None: expected_methods = { "CheckerOutputBindingPort": {"bind_checker_output"}, - "CheckerArtifactOutputPort": {"store", "recover"}, + "CheckerArtifactOutputPort": {"store", "recover", "read_response"}, } for name, methods in expected_methods.items(): assert { @@ -709,7 +709,11 @@ def _assert_checker_custody_consumers( "app/modules/artifacts/checker_outputs.py", "app/modules/artifacts/schemas.py", "app/modules/artifacts/service.py", - "app/modules/checkers/execution_coordination.py", + "app/modules/authorization/checker_output_authorization.py", + "app/modules/authorization/domain/checker_outputs.py", + "app/modules/checkers/model_calls/custody.py", + "app/modules/checkers/model_calls/proxy.py", + "app/modules/tasks/post_submit_routing/evaluation_guard.py", }, "app.modules.checkers.api.materialization": { "app/modules/authorization/domain/post_submit.py", diff --git a/backend/tests/test_checker_output_custody.py b/backend/tests/test_checker_output_custody.py index d42754f1f..33a6cdb42 100644 --- a/backend/tests/test_checker_output_custody.py +++ b/backend/tests/test_checker_output_custody.py @@ -3,15 +3,12 @@ from __future__ import annotations import asyncio -from contextlib import asynccontextmanager -from types import SimpleNamespace +import hashlib from uuid import UUID import pytest from sqlalchemy import func, select -from app.adapters.artifacts import checker_output_binding, checker_output_storage -from app.core.identifiers import new_record_id from app.interfaces.artifacts import ArtifactLimitExceededError from app.modules.actors.api import ServiceIdentity from app.modules.artifacts.models import ( @@ -26,70 +23,18 @@ from app.modules.checkers.api.output_custody import ( CheckerOutputArtifactRequest, CheckerOutputBindingRequest, - CheckerOutputSelector, CheckerOutputUnavailable, ) -from app.modules.checkers.models import CheckerRun +from app.modules.checkers.model_calls.models import CheckerModelCallAttempt from tests.checker_output_custody_helpers import ( byte_stream, output_custody_harness, selector_for, ) -from tests.checkers.execution.support import selected_request - - -async def test_default_composition_denies_before_owner_source_or_storage() -> None: - """Keep both production output capabilities unavailable before protected access.""" - - class Forbidden: - def __getattr__(self, name): - pytest.fail(f"protected access: {name}") - - @asynccontextmanager - async def session_scope(): - yield object() - - iterated = False - - async def source(): - nonlocal iterated - iterated = True - yield b"must-not-be-read" - - selector = CheckerOutputSelector( - evaluation=selected_request(), - checker_run_id=new_record_id(), - worker_lease_id=new_record_id(), - worker_lease_generation=1, - slot_key="platform-review", - ) - forbidden = Forbidden() - storage = checker_output_storage( - sessions=session_scope, - store=forbidden, - namespace=forbidden, - preparation=forbidden, - settings=forbidden, - ) - with pytest.raises(CheckerOutputUnavailable, match="write_unavailable"): - await storage.store(CheckerOutputArtifactRequest(selector, source())) - assert not iterated - - class Transaction: - def in_transaction(self): - return True - - def in_nested_transaction(self): - return False - - binding = checker_output_binding( - Transaction(), - namespace=SimpleNamespace(namespace_fingerprint="sha256:" + "0" * 64), - ) - with pytest.raises(CheckerOutputUnavailable, match="binding_unavailable"): - await binding.bind_checker_output( - CheckerOutputBindingRequest(selector, new_record_id(), new_record_id()) - ) + + +def content_sha256(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() @pytest.mark.parametrize("provider", ["local", "minio"]) @@ -136,8 +81,8 @@ async def test_real_store_verification_and_binding( row.put_attempt_id, row.verification_receipt_id, ) == ( - "checker_run", - str(harness.selector.checker_run_id), + "checker_model_call", + str(harness.selector.model_call_attempt_id), harness.selector.slot_key, ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value, "service_identity", @@ -199,7 +144,8 @@ async def test_identical_store_replay_reuses_verified_custody_without_provider_w select(func.count()) .select_from(ArtifactPutAttempt) .where( - ArtifactPutAttempt.checker_run_id == str(harness.selector.checker_run_id) + ArtifactPutAttempt.model_call_attempt_id + == str(harness.selector.model_call_attempt_id) ) ) == 1 @@ -239,13 +185,12 @@ async def test_foreign_lineage_and_changed_bytes_fail_before_provider( """Reject coherent foreign ownership before bytes and immutable replay drift before put.""" async with output_custody_harness(tmp_path, isolated_database_env) as harness: original_reservation = harness.state.reservation - original_selector = harness.selector - foreign = await harness.seed_reservation() + foreign = await harness.seed_reservation(content_sha256=content_sha256(b"foreign control")) foreign_selector = selector_for(foreign) - original = await harness.service.store( - harness.request(b"stable output", selector=original_selector) - ) + original = await harness.service.store(harness.request(b"stable output")) + original_reservation = harness.state.reservation + original_selector = harness.selector assert original.status == "verified" harness.state.reservation = foreign foreign_result = await harness.service.store( @@ -258,7 +203,9 @@ async def test_foreign_lineage_and_changed_bytes_fail_before_provider( harness.state.reservation = original_reservation for wrong_selector in ( foreign_selector, - original_selector.model_copy(update={"evaluation": foreign.evaluation}), + original_selector.model_copy( + update={"model_call_attempt_id": foreign_selector.model_call_attempt_id} + ), ): observed: list[bytes] = [] with pytest.raises(CheckerOutputUnavailable, match="reservation_unavailable"): @@ -288,11 +235,14 @@ async def test_binding_rejects_mixed_stored_lineage_before_consumption_or_mutati """Bind two valid controls, then reject every original/foreign custody mix.""" async with output_custody_harness(tmp_path, isolated_database_env) as harness: original_reservation = harness.state.reservation - original_selector = harness.selector - foreign_reservation = await harness.seed_reservation() + foreign_reservation = await harness.seed_reservation( + content_sha256=content_sha256(b"foreign binding control") + ) foreign_selector = selector_for(foreign_reservation) original = await harness.service.store(harness.request(b"original binding control")) + original_reservation = harness.state.reservation + original_selector = harness.selector harness.state.reservation = foreign_reservation foreign = await harness.service.store( harness.request(b"foreign binding control", selector=foreign_selector) @@ -386,9 +336,11 @@ async def bind_once(): assert sorted((first.replayed, second.replayed)) == [False, True] async with harness.factory() as session: count = await session.scalar( - select(func.count()).select_from(ArtifactBinding).where( - ArtifactBinding.resource_type == "checker_run", - ArtifactBinding.resource_id == str(harness.selector.checker_run_id), + select(func.count()) + .select_from(ArtifactBinding) + .where( + ArtifactBinding.resource_type == "checker_model_call", + ArtifactBinding.resource_id == str(harness.selector.model_call_attempt_id), ) ) assert count == 1 @@ -443,32 +395,6 @@ async def test_unknown_put_is_observed_recovered_and_bound_without_bytes( assert bound.content_id == recovered.content_id -async def test_worker_takeover_recovers_same_attempt_without_bytes( - tmp_path, - isolated_database_env, -) -> None: - """Fence the old lease and recover exact stored custody under its replacement.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: - old_selector = harness.selector - stored = await harness.service.store(harness.request(b"takeover output")) - puts = harness.store.puts - harness.state.reservation = harness.state.reservation.model_copy( - update={ - "worker_lease_id": new_record_id(), - "worker_lease_generation": old_selector.worker_lease_generation + 1, - } - ) - - with pytest.raises(CheckerOutputUnavailable, match="reservation_unavailable"): - await harness.service.recover(old_selector) - recovered = await harness.service.recover(harness.selector) - assert recovered is not None - assert recovered.put_attempt_id == stored.put_attempt_id - assert recovered.verification_receipt_id == stored.verification_receipt_id - assert recovered.replayed - assert harness.store.puts == puts - - async def test_authority_revocation_during_put_prevents_return_and_binding( tmp_path, isolated_database_env, @@ -480,33 +406,41 @@ async def test_authority_revocation_during_put_prevents_return_and_binding( operation = asyncio.create_task(harness.service.store(harness.request(b"revoked output"))) await asyncio.wait_for(harness.store.put_entered.wait(), timeout=10) async with harness.factory() as probe, probe.begin(): - locked_run = await probe.scalar( - select(CheckerRun) - .where(CheckerRun.id == str(harness.selector.checker_run_id)) + locked_effect = await probe.scalar( + select(CheckerModelCallAttempt) + .where(CheckerModelCallAttempt.id == str(harness.selector.model_call_attempt_id)) .with_for_update(nowait=True) ) locked_attempt = await probe.scalar( select(ArtifactPutAttempt) - .where(ArtifactPutAttempt.checker_run_id == str(harness.selector.checker_run_id)) + .where( + ArtifactPutAttempt.model_call_attempt_id + == str(harness.selector.model_call_attempt_id) + ) .with_for_update(nowait=True) ) - assert locked_run is not None and locked_attempt is not None + assert locked_effect is not None and locked_attempt is not None harness.state.revoked = True harness.store.put_release.set() with pytest.raises(CheckerOutputUnavailable, match="authority_revoked"): await operation async with harness.factory() as session: - attempt = (await session.scalars( - select(ArtifactPutAttempt).where( - ArtifactPutAttempt.checker_run_id == str(harness.selector.checker_run_id) + attempt = ( + await session.scalars( + select(ArtifactPutAttempt).where( + ArtifactPutAttempt.model_call_attempt_id + == str(harness.selector.model_call_attempt_id) + ) ) - )).one() - receipt = (await session.scalars( - select(ArtifactVerificationReceipt).join(ArtifactVerificationJob).where( - ArtifactVerificationJob.originating_put_attempt_id == attempt.id + ).one() + receipt = ( + await session.scalars( + select(ArtifactVerificationReceipt) + .join(ArtifactVerificationJob) + .where(ArtifactVerificationJob.originating_put_attempt_id == attempt.id) ) - )).one() + ).one() assert attempt is not None and receipt is not None attempt_id, receipt_id = UUID(attempt.id), UUID(receipt.id) await session.rollback() @@ -520,9 +454,11 @@ async def test_authority_revocation_during_put_prevents_return_and_binding( ) ) binding_count = await session.scalar( - select(func.count()).select_from(ArtifactBinding).where( - ArtifactBinding.resource_type == "checker_run", - ArtifactBinding.resource_id == str(harness.selector.checker_run_id), + select(func.count()) + .select_from(ArtifactBinding) + .where( + ArtifactBinding.resource_type == "checker_model_call", + ArtifactBinding.resource_id == str(harness.selector.model_call_attempt_id), ) ) assert binding_count == 0 @@ -563,7 +499,8 @@ async def test_cancellation_during_provider_put_cleans_scratch_and_retains_uncer async with harness.factory() as session: attempt = await session.scalar( select(ArtifactPutAttempt).where( - ArtifactPutAttempt.checker_run_id == str(harness.selector.checker_run_id) + ArtifactPutAttempt.model_call_attempt_id + == str(harness.selector.model_call_attempt_id) ) ) assert attempt is not None @@ -580,8 +517,8 @@ async def test_cancellation_during_provider_put_cleans_scratch_and_retains_uncer select(func.count()) .select_from(ArtifactOperationReceipt) .where( - ArtifactOperationReceipt.checker_run_id - == str(harness.selector.checker_run_id) + ArtifactOperationReceipt.model_call_attempt_id + == str(harness.selector.model_call_attempt_id) ) ) == 0 @@ -607,8 +544,8 @@ async def test_cancellation_during_provider_put_cleans_scratch_and_retains_uncer select(func.count()) .select_from(ArtifactBinding) .where( - ArtifactBinding.resource_type == "checker_run", - ArtifactBinding.resource_id == str(harness.selector.checker_run_id), + ArtifactBinding.resource_type == "checker_model_call", + ArtifactBinding.resource_id == str(harness.selector.model_call_attempt_id), ) ) == 0 diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index a9725cc11..358e87676 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -65,6 +65,17 @@ def test_architecture_inspections_share_capacity_without_losing_nodes() -> None: def test_checker_delivery_and_routing_preparation_partition_every_node_once() -> None: delivery = { + "tests/checkers_model_calls/test_composed_execution.py", + "tests/checkers_model_calls/test_model_call_contract.py", + "tests/checkers_model_calls/test_model_call_transactions_postgresql.py", + "tests/checkers_model_calls/test_model_invocation_grants_postgresql.py", + "tests/checkers_model_calls/test_model_runtime.py", + "tests/checkers_model_calls/test_proxy_orchestration.py", + "tests/checkers_model_calls/test_public_api.py", + "tests/checkers/model_provider/test_openai_responses.py", + "tests/checkers/external/test_model_transport.py", + "tests/checkers/external/test_model_proxy_transport.py", + "tests/authorization/checker_models/test_contracts.py", "tests/checkers/default/test_contract.py", "tests/checkers/default/test_execution.py", "tests/checkers/external/test_contracts.py", @@ -271,8 +282,8 @@ def test_semantic_family_membership_has_an_independent_fingerprint() -> None: "344de96250ca759b503588fada81e0552f80447ad4ba42052f91aeabb154fd56", ), "project": ( - 210, - "9e23ebc7b1631c6e858ae7bdc0425596236cada17b71a12e35afc518da1836b2", + 221, + "237f005197e5aac40fca5938d9b61efb5869b530b961ece79c1706475551e9ce", ), "task": ( 95, @@ -344,9 +355,9 @@ def test_project_lane_ownership_has_an_independent_fingerprint() -> None: ) + "\n" ) - assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 210 + assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 221 assert hashlib.sha256(canonical.encode()).hexdigest() == ( - "1f75e059d08244a1161dfe053003dee2e0e02268e2340943d4cf825d0b4cd0b4" + "f459732e6316c9a2153f196c5c59cc78137a1f38272ad4935acc07ac08a49b47" ) diff --git a/docker-compose.agent-loop.yml b/docker-compose.agent-loop.yml index 9ea5da844..2b875f6cd 100644 --- a/docker-compose.agent-loop.yml +++ b/docker-compose.agent-loop.yml @@ -10,6 +10,10 @@ # # The socket lives on a second named volume. Docker Desktop host-shared paths # refuse chmod on Unix sockets, and AF_UNIX paths must stay within 107 bytes. +# An optional backend model proxy uses its own private named volume. Invocation +# relay sockets use a fourth volume mounted in the checker at the volume's +# daemon-visible path so Docker can bind-mount an individual socket into an +# otherwise networkless checker image. x-agent-loop-backend: &agent-loop-backend environment: @@ -24,10 +28,33 @@ x-agent-loop-backend: &agent-loop-backend condition: service_completed_successfully services: - backend: *agent-loop-backend + backend: + <<: *agent-loop-backend worker: *agent-loop-backend beat: *agent-loop-backend + model-proxy: + extends: + file: docker-compose.yml + service: worker + profiles: ["model-proxy"] + command: [python, -m, app.workers.checker_model_proxy] + stop_grace_period: 125m + environment: + WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET: /run/ws-model-proxy/model-proxy.sock + WORKSTREAM_ARTIFACT_SCRATCH_ROOT: /var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-material/_data + volumes: + - checker_model_proxy:/run/ws-model-proxy + - checker_material:/var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-material/_data + depends_on: + checker-volumes-init: + condition: service_completed_successfully + healthcheck: + test: [CMD, python, -c, "import socket; s=socket.socket(socket.AF_UNIX); s.settimeout(2); s.connect('/run/ws-model-proxy/model-proxy.sock'); s.close()"] + interval: 5s + timeout: 3s + retries: 12 + # Named-volume roots start root-owned 0755. The scratch manager and checker # service both require a private 0700 root owned by the runtime UID. checker-volumes-init: @@ -44,13 +71,19 @@ services: - -c - | set -eu - for directory in "$$MATERIAL_ROOT" /run/ws-checker; do + for directory in \ + "$$MATERIAL_ROOT" \ + /run/ws-checker \ + /run/ws-model-proxy \ + /var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-relay/_data; do chown "$$RUNTIME_OWNER" "$$directory" chmod 0700 "$$directory" done volumes: - checker_material:/var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-material/_data - checker_socket:/run/ws-checker + - checker_model_proxy:/run/ws-model-proxy + - checker_model_relay:/var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-relay/_data # Loopback-only registry so the daemon records a platform-manifest digest. checker-registry: @@ -94,6 +127,8 @@ services: target: /var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-material/_data read_only: true - checker_socket:/run/ws-checker + - checker_model_proxy:/run/ws-model-proxy:ro + - checker_model_relay:/var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-relay/_data - /var/run/docker.sock:/var/run/docker.sock depends_on: checker-volumes-init: @@ -115,4 +150,7 @@ volumes: checker_material: name: ${COMPOSE_PROJECT_NAME}-checker-material checker_socket: + checker_model_proxy: + checker_model_relay: + name: ${COMPOSE_PROJECT_NAME}-checker-relay checker_registry: diff --git a/docs/architecture_checker_framework.md b/docs/architecture_checker_framework.md index 5625e9e16..6c40b8868 100644 --- a/docs/architecture_checker_framework.md +++ b/docs/architecture_checker_framework.md @@ -467,12 +467,14 @@ coded work findings, positive and negative cases, runtime dependencies and infrastructure failures. File and directory inputs use canonical relative paths. Capacity, deadline, unavailable material or implementation, and invalid output remain infrastructure outcomes rather than contributor work findings. Model -evaluation requires an explicit model-service dependency; task or guide context +evaluation requires a `model_service` dependency provisioned by the credential- +isolated `model_proxy`; task or guide context and external prepared images or caches require explicit material dependencies. `prepared_material` remains a runtime gap with `material_unavailable` handling; -it is not received package content and its absence is not a work failure. The ordinary image runner remains -offline and receives only its trusted request contract, so a desired dependency -must not be presented as available runtime capability. +it is not received package content and its absence is not a work failure. The +ordinary image runner remains networkless. Model access is available only to a +registered POST image through its exact approved project model profile; a missing +image or profile remains an unbound capability suggestion that blocks activation. The trusted compiler resolves each supported project check to an immutable Operator-registered image, validates bounded typed configuration against that diff --git a/docs/operations_checker_models.md b/docs/operations_checker_models.md new file mode 100644 index 000000000..d66289112 --- /dev/null +++ b/docs/operations_checker_models.md @@ -0,0 +1,120 @@ +# Project checker model calls + +Model review is part of a project's external POST image. The image owns its +prompt, rubric and interpretation. Workstream controls which model it can call, +the maximum spend, private evidence and recovery. The shared Rust runner only +provides the invocation's private socket; it contains no project rubric or +provider SDK. PRE images have no model capability. + +## Configure a project + +Register the project's digest-pinned POST image through the existing checker +registry. An Operator provisions an immutable model profile for that exact +project and registry entry, and a finite lifetime project budget. The profile +contains the provider/API pair, exact model snapshot, reasoning and verbosity, +byte/token/deadline bounds, and integer pricing with an immutable pricing +reference. The supported pair is `openai_responses` / `responses.v1`. The adapter +explicitly requests the provider's standard pricing tier. Verify the selected +snapshot's current standard rates before authorizing live spend. + +Include the complete selector in the existing policy proposal's image +configuration: + +```json +{ + "model_profile_id": "", + "model_profile_version": 1, + "model_profile_digest": "sha256:" +} +``` + +Project-specific configuration may accompany it when the registered image schema +allows those fields. The PM approves this configuration through the existing +policy path. A partial selector, a profile from another project/image, or a PRE +binding is invalid. The setup specification identifies `model_service` with +`model_proxy` provisioning; this does not waive registration, approval or +activation checks. Required unbound capabilities still block activation. + +Budget amounts use integer USD nanodollars: US$10 is `10000000000`. A later budget +configuration changes the ceiling without resetting settled spend or outstanding +reservations. A project cannot lower its ceiling below the sum already committed. +The budget belongs to Workstream-paid evaluation, not contributor compensation. + +## Runtime and credentials + +For the local agent-loop compose overlay, enable the `model-proxy` profile and +start its singleton `model-proxy` service. It runs +`python -m app.workers.checker_model_proxy` with +`WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET=/run/ws-model-proxy/model-proxy.sock`. +Ordinary API workers do not own this listener. The backend's existing +`OPENAI_API_KEY` supplies credentials; never put that key in an image, policy, +checker input or command output. Configure the Rust service's `model_proxy_socket` +to the same path and `model_relay_root` to its private daemon-visible relay root. +The local configuration generator supports `--enable-model-proxy` for those Rust +settings. See the [external checker protocol](../external_checkers/README.md). + +Run exactly one model-proxy process owning a given socket. The listener +requires an existing private directory owned by its process user, refuses an +occupied socket path, and drains admitted handlers during graceful shutdown. +After a forced process kill, confirm the old owner has stopped before removing +its stale socket and restarting. Restart does not clear reservations or replay +provider creates. The proxy's durable tables and private ART objects remain the +recovery authority. + +## What happens for each call + +1. A current POST execution receives one short-lived invocation grant for its + exact locked binding. The grant never enters the image. Rust exposes only + `/work/model.sock` to that image, with ordinary networking still disabled. +2. The image sends one bounded prompt. Workstream authorizes it, binds the exact + request hash to a stable paid-call slot and consumes the invocation grant. + A successor checker run uses the same slot; a changed prompt is a conflict. +3. Workstream retains and verifies the request through ART. In one PostgreSQL + transaction it reserves the profile's maximum possible charge and commits + dispatch intent. Only the caller that first committed that intent may create + the provider response. Network I/O occurs outside the database transaction. +4. The provider input-token count must fit the approved profile. The adapter + disables automatic create retries and requests a stored background response. + It persists a returned provider ID before polling or retaining terminal bytes. +5. Authenticated terminal output and complete usage are retained through ART. + Settlement replaces the reservation once, using the locked integer rates. + Cached input is priced separately; reasoning tokens are part of output tokens + and are not charged twice. A current authorized invocation receives only + bounded output and a receipt digest, never provider coordinates. +6. The image interprets that output and returns the ordinary normalized checker + result. Workstream verifies the corresponding completed call before accepting + a completed model-image result. Existing routing decides acceptance or + contributor revision; the model proxy cannot do either. + +## Failure and recovery + +| Condition | Retained effect | Permitted next action | +| --- | --- | --- | +| Budget unavailable | No provider dispatch | Configure sufficient budget, then use existing checker recovery | +| Input counting proves no create occurred | `known_no_effect`; reservation released | Explicit Operator-authorized child attempt, then checker recovery | +| Create response lost, no provider ID | Uncertain; full reservation retained | Investigate; no automatic or Operator child create without proof of no effect | +| Known provider ID still pending/unavailable | Original effect and reservation retained | Retrieve that ID through a fresh authorized execution | +| Terminal failure without complete usage | Uncertain; reservation retained | Recover complete usage for the original ID; no paid child yet | +| Terminal failure with complete settled usage | `known_failed`, immutable evidence | Explicit Operator-authorized child attempt, then checker recovery | +| Completed call but worker/image response lost | Completed effect, original ART bytes and settled cost | Fresh current execution attaches the same cached completion | + +Infrastructure and budget failures never consume a contributor revision. A late +provider response may finish the original paid call after its checker lease has +ended, but cannot deliver output to a stale invocation or create another effect. +Recovery requires current authorization; knowing a row ID is not authority. + +Request/response evidence is private ART content. Ledger rows contain identities, +hashes, pricing and usage, not transcript bytes. Do not put transcripts or provider +coordinates in contributor findings, logs, exports or outbox payloads. + +## Verification boundaries + +Provider transport tests use the real SDK with a recorded HTTP transport; they +make no live calls. PostgreSQL tests cover budget serialization, immutable +receipts and invocation consumption. ART tests exercise storage/verification and +failure recovery. Unix socket tests prove framing and lifecycle locally. +Docker development isolation does not qualify gVisor: the mounted Unix socket +requires administrator-enabled `host-uds=open` and a separate real gVisor run. +Record the exact image digest, profile, budget, tested revision, provider usage +and retained effect identities for a live rehearsal; passing unit counts do not +establish the project's complete activation-to-contribution journey. diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index e3090a2a7..63bcb68f7 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -502,7 +502,7 @@ cannot be reused as post-submission review-gate evidence. See the | Contribution evidence reads | **Public fixed-membership REST and complete CLI export** | PILOT-10A exposes current human self-read and one-project JSON/CSV export for covering Project Managers or exact-project Finance Authorities. Each page has fresh authority and one repeatable-read response transaction; selections through 100,000 contributions retain page-one membership behind signed scope/filter/format/limit-bound cursors, with complete zero/one/two immutable awards, exact decimals, recorded-time filters and formula-neutralized CSV. The CLI follows JSON or CSV continuation internally with unchanged filters, validates cross-page order/identity, and publishes only the complete JSON result or one private no-overwrite CSV | No recognition mutation, fulfillment, payout, deployed-service or published-binary claim | | Fulfillment, reconciliation, and audit | **Planned** | Shared audit foundations, provider-neutral adapter convention, AUTH-OUTBOX-02 live dispatcher authority, retained phase audit decisions, Celery delivery/recovery scans and CON-02B custody | Feature-specific handlers and authority, conditional award fulfillment, callbacks, idempotent recovery, reconciliation, bounded operational reads, and release controls | | Runtime diagnostics | **Implemented foundation; release configuration prepared** | Closed structured logging, explicit API/Celery tracing, bounded metrics, safe correlation and optional typed OTLP export; hosted composition accepts only an optional HTTPS collector origin and keeps exporter outage outside product correctness | Restrict broker publishers; configure and secure collector/log access, egress, encryption and finite deletion; prove privacy, correlation and collector outage during the deployed release drill; no deployed monitoring claim | -| Frontend and pilot | **Local runtime foundation; release configuration prepared; project inputs prepared** | An original [Terminal Systems guide, example and five task briefs](../pilots/terminal_systems/README.md) provide inputs for fresh project onboarding. The fixed five-task sample, its checker, private runtime continuation and sample-only proof workflow are retired. Checkout-isolated Docker Compose API, prefork Celery, Beat, PostgreSQL, Redis and MinIO remain available, alongside digest-only hosted release composition and the generic external image service. The generic image command tests project samples and publishes a verified digest to ECR without registering or activating it. The opt-in [local agent-loop overlay](engineering/local-agent-loop.md) adds that service, a separately built networkless proof checker and operator scripts; Docker Desktop (Apple Silicon) proof covers registration, checker execution and lifecycle activation, not this project’s requirements. Setup supports inference and explicitly manager-authored rules through the same governed proposal path. The corrected real Terminal Systems proposal preserves null project limits on the unchanged guide; independent review qualified its checker specifications and classifications, with ten explicit implementation/runtime gaps and no bindings in that retained proposal. The [project-owned PRE image](../pilots/terminal_systems/checkers/README.md) now implements layout and metadata intake with local rule and networkless container samples; it has no task-name allowlist or shared-runner project code. The independent offline gVisor feasibility spike remains historical mechanism evidence | Implement the eight POST checks and resolve model, isolated-execution and authenticated-material gaps; register the required images, obtain corrected policy bindings and separate approvals, then activate, import, screen and release tasks. Then prove the real contribution journey, runtime limits and interruption recovery on the intended local/hosted stack. Guide assets, image builds and former sample results are not live-project evidence. Hosted identity/AWS configuration, isolation, diagnostics, backup/restore and broader macOS runtime proof remain; the agent-loop overlay still needs approved guide bindings and a persistent-stack FinalAcceptance drill | +| Frontend and pilot | **Local runtime foundation; release configuration prepared; project inputs prepared** | An original [Terminal Systems guide, example and five task briefs](../pilots/terminal_systems/README.md) provide inputs for fresh project onboarding. The fixed five-task sample, its checker, private runtime continuation and sample-only proof workflow are retired. Checkout-isolated Docker Compose API, prefork Celery, Beat, PostgreSQL, Redis and MinIO remain available, alongside digest-only hosted release composition and the generic external image service. The generic image command tests project samples and publishes a verified digest to ECR without registering or activating it. The opt-in [local agent-loop overlay](engineering/local-agent-loop.md) adds that service, a separately built networkless proof checker and operator scripts; Docker Desktop (Apple Silicon) proof covers registration, checker execution and lifecycle activation, not this project’s requirements. Setup supports inference and explicitly manager-authored rules through the same governed proposal path. The corrected real Terminal Systems proposal preserves null project limits on the unchanged guide; independent review qualified its checker specifications and classifications, with ten explicit implementation/runtime gaps and no bindings in that retained proposal. The [project-owned PRE image](../pilots/terminal_systems/checkers/README.md) now implements layout and metadata intake with local rule and networkless container samples; it has no task-name allowlist or shared-runner project code. The independent offline gVisor feasibility spike remains historical mechanism evidence | Implement the eight POST images and resolve isolated-execution and authenticated-material gaps. Model-enabled checks also need exact approved project profiles and use only the credential-isolated proxy; a missing image or profile remains unbound and blocks activation. Register the required images, obtain corrected policy bindings and separate approvals, then activate, import, screen and release tasks. Then prove the real contribution journey, runtime limits and interruption recovery on the intended local/hosted stack. Guide assets, image builds and former sample results are not live-project evidence. Hosted identity/AWS configuration, isolation, diagnostics, backup/restore and broader macOS runtime proof remain; the agent-loop overlay still needs approved guide bindings and a persistent-stack FinalAcceptance drill | ## What Has Been Completed @@ -1526,8 +1526,9 @@ and unapproved. This qualification required explicit PM corrections and establis one reviewed handoff, not automatic completeness for arbitrary guides. Ten required checks remain unbound suggestions in that immutable proposal. The project-owned PRE image now implements both PRE specifications and is locally qualified; it is -not yet published, registered or bound. The eight POST checks and their model, -execution and authenticated-material dependencies remain to be implemented. +not yet published, registered or bound. The eight POST images and their execution +and authenticated-material dependencies remain to be implemented. Model-enabled +checks also require exact approved project profiles before binding. Registration, corrected policy binding and approval must precede activation; no approved binding, activated guide or contribution outcome is established. diff --git a/external_checkers/README.md b/external_checkers/README.md index aef3e65bb..8128236a5 100644 --- a/external_checkers/README.md +++ b/external_checkers/README.md @@ -52,6 +52,52 @@ supported isolation and resource contract. Unsupported runtime needs must be resolved explicitly before approving that capability; they are not permission to add a named-project exception or expose the host Docker socket. +## Optional model socket + +An entitled post-submit invocation may receive one invocation-local Unix socket +at `/work/model.sock`. The image opens one connection and sends exactly +`{"prompt":""}` as UTF-8 JSON, prefixed by a four-byte unsigned +big-endian body length. The JSON body must be non-empty and no larger than 1 MiB. +The image then reads one frame in the same format. Its body is one closed +`workstream.checker_model_proxy.v1` result object: + +- A completed result has `outcome: "completed"`, a non-empty string `output`, a + lowercase `sha256:` `receipt_digest` and a null `failure_code`. +- An unavailable result has `outcome: "unavailable"`, null `output` and + `receipt_digest`, and a bounded lowercase-identifier `failure_code`. + +Both results also contain the exact `protocol_version`; extra or missing fields +are invalid. This image-facing protocol is project-agnostic. The image needs no +Workstream project code, provider SDK, provider credential, model coordinate or +access to the global proxy endpoint. The service wraps the prompt with the +invocation's private grant and request digest. It offers one call slot and keeps +the checker container on `--network none`. + +The dedicated singleton model-proxy worker is optional. Its `model-proxy` +Compose profile sets +`WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET=/run/ws-model-proxy/model-proxy.sock` and +binds the listener; the ordinary API, task worker and beat processes do not own +or mount that socket. An empty value outside the model-proxy process leaves the +listener disabled. The Rust service configuration must set both +`model_proxy_socket` to that same mounted path and `model_relay_root` to a +daemon-visible private relay directory. The local configuration generator's +`--enable-model-proxy` switch emits that pair, using +`/var/lib/docker/volumes/-checker-relay/_data` for the relay +root. Enabling only one side does not activate model calls. + +The proxy and relay roots must be canonical mode-`0700` directories owned by the +shared service UID and GID. The global proxy socket and each invocation socket +are mode `0600`. The backend has read-write access to the proxy volume, the Rust +service connects through its read-only volume mount and creates invocation +sockets in the relay volume, and a checker receives only its individual socket +file. A different service/sandbox identity is rejected because this path does +not rely on privileged ownership changes. + +Docker development proves only the ordinary runtime path. A gVisor `runsc` +deployment must separately enable `host-uds=open` before a mounted host Unix +socket can work. This repository does not claim that the model relay has been +qualified under gVisor. + ## From guide to an active project Use the real guide and task examples as setup input. Inspect the resulting diff --git a/external_checkers/harnessy_proof/publish_local.py b/external_checkers/harnessy_proof/publish_local.py index 1f3647b8c..4521a6a00 100644 --- a/external_checkers/harnessy_proof/publish_local.py +++ b/external_checkers/harnessy_proof/publish_local.py @@ -36,6 +36,7 @@ # The socket stays on a named volume: Docker Desktop host-shared paths refuse # chmod on Unix sockets, and AF_UNIX paths must stay within 107 bytes. SOCKET_PATH = "/run/ws-checker/checker.sock" +MODEL_PROXY_SOCKET = "/run/ws-model-proxy/model-proxy.sock" def material_root(compose_project: str) -> str: @@ -43,6 +44,11 @@ def material_root(compose_project: str) -> str: return f"/var/lib/docker/volumes/{compose_project}-checker-material/_data" +def model_relay_root(compose_project: str) -> str: + """Daemon-visible private root for invocation-local model sockets.""" + return f"/var/lib/docker/volumes/{compose_project}-checker-relay/_data" + + def _hash(value: object) -> str: """Canonical JSON digest for the string-only schema documents used here.""" encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) @@ -79,9 +85,14 @@ def registry_spec(phase: str, image_digest: str, capability_version: str) -> dic def service_config( - image: dict[str, object], *, compose_project: str, sandbox_uid: int, sandbox_gid: int + image: dict[str, object], + *, + compose_project: str, + sandbox_uid: int, + sandbox_gid: int, + enable_model_proxy: bool = False, ) -> dict[str, object]: - return { + config = { "socket_path": SOCKET_PATH, "material_root": material_root(compose_project), "docker_binary": "/usr/local/bin/docker", @@ -93,6 +104,20 @@ def service_config( "sandbox_gid": sandbox_gid, "cache": [image], } + if enable_model_proxy: + relay_root = model_relay_root(compose_project) + maximum_socket = f"{relay_root}/.model-{'0' * 16}/model.sock" + if len(maximum_socket.encode()) >= 108: + raise ValueError( + "compose project makes the model relay socket path too long" + ) + config.update( + { + "model_proxy_socket": MODEL_PROXY_SOCKET, + "model_relay_root": relay_root, + } + ) + return config def _docker(*arguments: str) -> str: @@ -146,6 +171,11 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--sandbox-uid", type=int, required=True) parser.add_argument("--sandbox-gid", type=int, required=True) parser.add_argument("--out-dir", type=Path, required=True) + parser.add_argument( + "--enable-model-proxy", + action="store_true", + help="emit fixed local proxy and daemon-visible relay paths", + ) # The registry holds one immutable entry per phase, capability and version, # so a rebuilt image (new digest) needs a new version. parser.add_argument("--capability-version", default="v1") @@ -158,6 +188,7 @@ def main(argv: list[str] | None = None) -> int: compose_project=args.compose_project, sandbox_uid=args.sandbox_uid, sandbox_gid=args.sandbox_gid, + enable_model_proxy=args.enable_model_proxy, ), "registry-pre.json": registry_spec( "pre_submit", image["platform_manifest_digest"], args.capability_version diff --git a/external_checkers/workstream-checker-service/src/main.rs b/external_checkers/workstream-checker-service/src/main.rs index 228b79bd9..1cefaaf8f 100644 --- a/external_checkers/workstream-checker-service/src/main.rs +++ b/external_checkers/workstream-checker-service/src/main.rs @@ -1,5 +1,11 @@ //! Trusted long-lived Unix-socket boundary for digest-pinned checker containers. +mod model_relay; + +use model_relay::{ + deserialize_required_model_grant, valid_model_grant, validate_proxy_socket, + validate_relay_root, InvocationRelay, ModelGrant, RequiredModelGrant, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -36,6 +42,8 @@ struct ServiceConfig { #[serde(skip)] container_owner: Option, socket_path: PathBuf, + model_proxy_socket: Option, + model_relay_root: Option, material_root: PathBuf, docker_binary: PathBuf, runtime: String, @@ -66,6 +74,8 @@ struct ExecuteEnvelope { operation: String, request: Value, grant: GrantReference, + #[serde(deserialize_with = "deserialize_required_model_grant")] + model_grant: RequiredModelGrant, } #[derive(Debug, Deserialize)] @@ -588,6 +598,17 @@ fn validate_config(config: &ServiceConfig) -> Result<(), String> { { return Err("socket parent is not private".into()); } + match (&config.model_proxy_socket, &config.model_relay_root) { + (Some(model_proxy_socket), Some(model_relay_root)) => { + validate_proxy_socket(model_proxy_socket)?; + validate_relay_root(model_relay_root)?; + if (config.sandbox_uid, config.sandbox_gid) != (effective_uid, effective_gid) { + return Err("model relay identity differs".into()); + } + } + (None, None) => {} + _ => return Err("model relay configuration incomplete".into()), + } let docker = fs::symlink_metadata(&config.docker_binary).map_err(|_| "docker binary missing")?; if !docker.file_type().is_file() @@ -665,6 +686,14 @@ fn handle_frame( } let request_bytes = serde_json::to_vec(&envelope.request).map_err(|_| "request invalid")?; let request = parse_request(&request_bytes).map_err(|_| "request contract invalid")?; + if let Some(model_grant) = envelope.model_grant.0.as_ref() { + if config.model_proxy_socket.is_none() + || config.model_relay_root.is_none() + || !valid_model_grant(model_grant, &request.request_digest, &request.phase) + { + return Err("model grant invalid".into()); + } + } let Some(cache) = config .cache .iter() @@ -698,6 +727,7 @@ fn handle_frame( cache, &request, &grant.workspace, + envelope.model_grant.0.as_ref(), stream, &mut budget, ) { @@ -1334,6 +1364,85 @@ fn execute_container( cache: &CacheEntry, request: &ValidatedRequest, workspace: &Path, + model_grant: Option<&ModelGrant>, + client: &UnixStream, + budget: &mut RequestExecutionBudget, +) -> Result { + container_owner(config) + .map_err(|_| ExecutionFailure::ordinary("implementation_unavailable", false))?; + inspect_workspace(workspace, config.sandbox_uid, config.sandbox_gid) + .map_err(|_| ExecutionFailure::ordinary("material_unavailable", false))?; + let relay = match model_grant { + Some(grant) => { + let Some(proxy_path) = config.model_proxy_socket.as_deref() else { + return Err(ExecutionFailure::ordinary( + "implementation_unavailable", + false, + )); + }; + let Some(relay_root) = config.model_relay_root.as_deref() else { + return Err(ExecutionFailure::ordinary( + "implementation_unavailable", + false, + )); + }; + Some( + InvocationRelay::start( + relay_root, + proxy_path, + grant, + &request.request_digest, + config.sandbox_uid, + config.sandbox_gid, + budget.deadline, + ) + .map_err(|failure| { + if failure.cleanup_unsettled { + ExecutionFailure::unsettled(false) + } else { + ExecutionFailure::ordinary("implementation_unavailable", false) + } + })?, + ) + } + None => None, + }; + let model_mount = relay.as_ref().map(InvocationRelay::mount_spec); + let result = execute_container_inner( + config, + cache, + request, + workspace, + model_mount.as_deref(), + client, + budget, + ); + let Some(relay) = relay else { + return result; + }; + let relay_result = relay.finish(); + let launched = match &result { + Ok(_) => true, + Err(failure) => failure.launched, + }; + if relay_result.cleanup_unsettled { + return Err(ExecutionFailure::unsettled(launched)); + } + if relay_result.failed { + return Err(ExecutionFailure::ordinary( + "implementation_unavailable", + launched, + )); + } + result +} + +fn execute_container_inner( + config: &ServiceConfig, + cache: &CacheEntry, + request: &ValidatedRequest, + workspace: &Path, + model_mount: Option<&str>, client: &UnixStream, budget: &mut RequestExecutionBudget, ) -> Result { @@ -1358,43 +1467,23 @@ fn execute_container( "type=bind,src={},dst=/work/input,readonly", workspace.display() ); - let create_arguments = [ - "create", - "--name", + let create_arguments = container_create_arguments( &name, - "--label", - "workstream.external-checker=true", - "--label", &attempt_label, - "--label", &owner_label, - "--runtime", &config.runtime, - "--network", - "none", - "--read-only", - "--user", &sandbox_user, - "--cap-drop", - "ALL", - "--security-opt", - "no-new-privileges", - "--pids-limit", &pids, - "--memory", &memory, - "--cpus", &cpus, - "--tmpfs", - "/tmp:rw,noexec,nosuid,nodev,size=16777216", - "--mount", &mount, - "-i", + model_mount, &image, - ]; + ); + let create_argument_refs: Vec<_> = create_arguments.iter().map(String::as_str).collect(); let created = match run_bounded_command_cancellable( &config.docker_binary, - &create_arguments, + &create_argument_refs, budget.deadline, budget.output.remaining, client, @@ -1460,6 +1549,60 @@ fn execute_container( result.map_err(|code| ExecutionFailure::ordinary(code, true)) } +#[allow(clippy::too_many_arguments)] +fn container_create_arguments( + name: &str, + attempt_label: &str, + owner_label: &str, + runtime: &str, + sandbox_user: &str, + pids: &str, + memory: &str, + cpus: &str, + material_mount: &str, + model_mount: Option<&str>, + image: &str, +) -> Vec { + let mut arguments = vec![ + "create".to_string(), + "--name".into(), + name.into(), + "--label".into(), + "workstream.external-checker=true".into(), + "--label".into(), + attempt_label.into(), + "--label".into(), + owner_label.into(), + "--runtime".into(), + runtime.into(), + "--network".into(), + "none".into(), + "--read-only".into(), + "--user".into(), + sandbox_user.into(), + "--cap-drop".into(), + "ALL".into(), + "--security-opt".into(), + "no-new-privileges".into(), + "--pids-limit".into(), + pids.into(), + "--memory".into(), + memory.into(), + "--cpus".into(), + cpus.into(), + "--tmpfs".into(), + "/tmp:rw,noexec,nosuid,nodev,size=16777216".into(), + "--mount".into(), + material_mount.into(), + ]; + if let Some(model_mount) = model_mount { + arguments.push("--mount".into()); + arguments.push(model_mount.into()); + } + arguments.extend(["-i".into(), image.into()]); + arguments +} + fn remove_container_work( config: &ServiceConfig, name: &str, @@ -2143,6 +2286,8 @@ mod tests { load_container_owner(&File::open(&socket_root).unwrap()).unwrap(), ), socket_path: socket_root.join("checker.sock"), + model_proxy_socket: None, + model_relay_root: None, material_root, docker_binary: docker, runtime: "runc".into(), @@ -2238,6 +2383,7 @@ mod tests { "grant_id": grant_id, "binding_digest": manifest.binding_digest, }, + "model_grant": null, }) } @@ -2698,6 +2844,7 @@ exit 2 "grant_id": "extract_0123456789abcdef0123456789abcdef", "binding_digest": format!("sha256:{}", "6".repeat(64)), }, + "model_grant": null, }); let (mut client, mut server) = UnixStream::pair().unwrap(); write_frame(&mut client, &serde_json::to_vec(&envelope).unwrap()).unwrap(); @@ -2730,6 +2877,110 @@ exit 2 assert!(serde_json::from_value::(configuration).is_err()); } + #[test] + fn execute_wire_requires_an_exact_model_grant_member() { + let (root, config) = test_config(); + let envelope = pre_submit_envelope_with_grant(&config); + assert!(serde_json::from_value::(envelope.clone()).is_ok()); + + let mut missing = envelope.clone(); + missing.as_object_mut().unwrap().remove("model_grant"); + assert!(serde_json::from_value::(missing).is_err()); + + let mut extended = envelope; + extended["model_grant"] = serde_json::json!({ + "grant_id": format!("model_{}", "1".repeat(32)), + "binding_digest": extended["request"]["request_digest"], + "extra": true, + }); + assert!(serde_json::from_value::(extended).is_err()); + let workspace = config + .material_root + .join("workspaces/extract_0123456789abcdef0123456789abcdef/workspace"); + fs::set_permissions(workspace, fs::Permissions::from_mode(0o700)).unwrap(); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn model_grants_are_rejected_outside_the_bound_post_request() { + let (root, mut config) = test_config(); + config.model_proxy_socket = Some(PathBuf::from("/unused/private/proxy.sock")); + config.model_relay_root = Some(PathBuf::from("/unused/private/relay")); + let mut envelope = pre_submit_envelope_with_grant(&config); + envelope["model_grant"] = serde_json::json!({ + "grant_id": format!("model_{}", "1".repeat(32)), + "binding_digest": envelope["request"]["request_digest"], + }); + let (_client, server) = UnixStream::pair().unwrap(); + assert_eq!( + handle_frame(&config, &server, &serde_json::to_vec(&envelope).unwrap()), + Err("model grant invalid".into()) + ); + + let workspace = config + .material_root + .join("workspaces/extract_0123456789abcdef0123456789abcdef/workspace"); + fs::set_permissions(workspace, fs::Permissions::from_mode(0o700)).unwrap(); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn container_arguments_mount_only_the_invocation_model_socket() { + let material = "type=bind,src=/private/material,dst=/work/input,readonly"; + let invocation = + "type=bind,src=/private/runtime/.model-123/model.sock,dst=/work/model.sock,readonly"; + let arguments = container_create_arguments( + "candidate", + "workstream.external-checker-attempt=a", + "workstream.external-checker-owner=b", + "runc", + "65532:65532", + "16", + "16777216", + "1.000", + material, + Some(invocation), + "registry.example/checker@sha256:123", + ); + assert!(arguments + .windows(2) + .any(|pair| pair == ["--network", "none"])); + let mounts: Vec<_> = arguments + .windows(2) + .filter(|pair| pair[0] == "--mount") + .map(|pair| pair[1].as_str()) + .collect(); + assert_eq!(mounts, [material, invocation]); + assert!(!arguments.iter().any(|value| { + value.contains("proxy.sock") + || value.contains("model_0123456789abcdef0123456789abcdef") + || value.contains("provider") + || value.contains("api_key") + })); + + let without_model = container_create_arguments( + "candidate", + "workstream.external-checker-attempt=a", + "workstream.external-checker-owner=b", + "runc", + "65532:65532", + "16", + "16777216", + "1.000", + material, + None, + "registry.example/checker@sha256:123", + ); + assert_eq!( + without_model + .windows(2) + .filter(|pair| pair[0] == "--mount") + .map(|pair| pair[1].as_str()) + .collect::>(), + [material] + ); + } + #[test] fn checker_output_cannot_request_another_execution() { let (root, config) = test_config(); @@ -3073,6 +3324,7 @@ exit 2 &config.cache[0], &request, &workspace, + None, &client, &mut budget, ), @@ -3201,6 +3453,7 @@ exit 2 &config.cache[0], &request, &workspace, + None, &client, &mut budget, ), @@ -3236,6 +3489,7 @@ exit 2 &config.cache[0], &request, &workspace, + None, &client, &mut budget, ), diff --git a/external_checkers/workstream-checker-service/src/model_relay.rs b/external_checkers/workstream-checker-service/src/model_relay.rs new file mode 100644 index 000000000..a3d9f296d --- /dev/null +++ b/external_checkers/workstream-checker-service/src/model_relay.rs @@ -0,0 +1,1048 @@ +//! Invocation-local, single-use relay from a checker image to the trusted model proxy. + +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::fs::{self, DirBuilder, File}; +use std::io::{Read, Write}; +use std::os::fd::FromRawFd; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{DirBuilderExt, FileTypeExt, MetadataExt, PermissionsExt}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +pub(crate) const CONTAINER_MODEL_SOCKET: &str = "/work/model.sock"; +const MODEL_PROXY_PROTOCOL: &str = "workstream.checker_model_proxy.v1"; +const MODEL_FRAME_MAXIMUM: usize = 1024 * 1024; +const INVOCATION_DIRECTORY_PREFIX: &str = ".model-"; + +#[derive(Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct ModelGrant { + pub(crate) grant_id: String, + pub(crate) binding_digest: String, +} + +impl fmt::Debug for ModelGrant { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ModelGrant()") + } +} + +/// The execute-envelope field uses an explicit deserializer so JSON `null` is +/// valid while an omitted `model_grant` member remains invalid. +#[derive(Clone, Deserialize)] +#[serde(transparent)] +pub(crate) struct RequiredModelGrant(pub(crate) Option); + +impl fmt::Debug for RequiredModelGrant { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + Some(_) => formatter.write_str("RequiredModelGrant(Some())"), + None => formatter.write_str("RequiredModelGrant(None)"), + } + } +} + +pub(crate) fn deserialize_required_model_grant<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(RequiredModelGrant) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ImageModelRequest { + prompt: String, +} + +impl fmt::Debug for ImageModelRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ImageModelRequest()") + } +} + +#[derive(Serialize)] +struct ProxyModelRequest<'a> { + protocol_version: &'static str, + grant_id: &'a str, + request_digest: &'a str, + prompt: &'a str, +} + +impl fmt::Debug for ProxyModelRequest<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ProxyModelRequest()") + } +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProxyModelResponse { + protocol_version: String, + outcome: String, + #[serde(deserialize_with = "deserialize_required_optional_string")] + output: Option, + #[serde(deserialize_with = "deserialize_required_optional_string")] + receipt_digest: Option, + #[serde(deserialize_with = "deserialize_required_optional_string")] + failure_code: Option, +} + +impl fmt::Debug for ProxyModelResponse { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ProxyModelResponse()") + } +} + +fn deserialize_required_optional_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer) +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct RelayFinish { + pub(crate) failed: bool, + pub(crate) cleanup_unsettled: bool, +} + +#[derive(Debug)] +pub(crate) struct RelayStartFailure { + pub(crate) cleanup_unsettled: bool, +} + +impl RelayStartFailure { + fn ordinary() -> Self { + Self { + cleanup_unsettled: false, + } + } +} + +#[derive(Debug)] +struct RelayPaths { + directory: PathBuf, + socket: PathBuf, + directory_device: u64, + directory_inode: u64, + socket_device: u64, + socket_inode: u64, + sandbox_uid: u32, + sandbox_gid: u32, +} + +pub(crate) struct InvocationRelay { + paths: RelayPaths, + cancelled: Arc, + worker: Option>>, +} + +impl InvocationRelay { + pub(crate) fn start( + relay_root: &Path, + proxy_path: &Path, + grant: &ModelGrant, + request_digest: &str, + sandbox_uid: u32, + sandbox_gid: u32, + deadline: Instant, + ) -> Result { + validate_proxy_socket(proxy_path).map_err(|_| RelayStartFailure::ordinary())?; + validate_relay_root(relay_root).map_err(|_| RelayStartFailure::ordinary())?; + // The unprivileged service cannot chown after dropping every capability. + // Requiring the sandbox identity to match makes the newly bound socket + // inherit its final owner without a privileged operation. + // SAFETY: effective IDs are read-only process attributes. + let (effective_uid, effective_gid) = unsafe { (libc::geteuid(), libc::getegid()) }; + if (sandbox_uid, sandbox_gid) != (effective_uid, effective_gid) { + return Err(RelayStartFailure::ordinary()); + } + let directory = + create_invocation_directory(relay_root).map_err(|_| RelayStartFailure::ordinary())?; + let socket = directory.join("model.sock"); + let listener = match UnixListener::bind(&socket) { + Ok(listener) => listener, + Err(_) => { + return Err(RelayStartFailure { + cleanup_unsettled: fs::remove_dir(&directory).is_err(), + }); + } + }; + if listener.set_nonblocking(true).is_err() + || fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).is_err() + { + drop(listener); + return Err(remove_created_paths(&directory, &socket)); + } + let directory_metadata = match fs::symlink_metadata(&directory) { + Ok(metadata) => metadata, + Err(_) => { + drop(listener); + return Err(remove_created_paths(&directory, &socket)); + } + }; + let socket_metadata = match fs::symlink_metadata(&socket) { + Ok(metadata) => metadata, + Err(_) => { + drop(listener); + return Err(remove_created_paths(&directory, &socket)); + } + }; + if !private_process_directory(&directory_metadata) + || !sandbox_socket(&socket_metadata, sandbox_uid, sandbox_gid) + { + drop(listener); + return Err(remove_created_paths(&directory, &socket)); + } + let paths = RelayPaths { + directory, + socket, + directory_device: directory_metadata.dev(), + directory_inode: directory_metadata.ino(), + socket_device: socket_metadata.dev(), + socket_inode: socket_metadata.ino(), + sandbox_uid, + sandbox_gid, + }; + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = Arc::clone(&cancelled); + let proxy = proxy_path.to_owned(); + let grant = grant.clone(); + let digest = request_digest.to_owned(); + let worker = match thread::Builder::new() + .name("checker-model-relay".into()) + .spawn(move || { + relay_once( + listener, + &proxy, + &grant, + &digest, + deadline, + &worker_cancelled, + ) + }) { + Ok(worker) => worker, + Err(_) => { + return Err(RelayStartFailure { + cleanup_unsettled: !cleanup_paths(&paths), + }); + } + }; + Ok(Self { + paths, + cancelled, + worker: Some(worker), + }) + } + + pub(crate) fn mount_spec(&self) -> String { + format!( + "type=bind,src={},dst={CONTAINER_MODEL_SOCKET},readonly", + self.paths.socket.display() + ) + } + + #[cfg(test)] + pub(crate) fn socket_path(&self) -> &Path { + &self.paths.socket + } + + pub(crate) fn finish(mut self) -> RelayFinish { + self.cancelled.store(true, Ordering::Release); + let failed = self + .worker + .take() + .is_some_and(|worker| !matches!(worker.join(), Ok(Ok(())))); + let cleanup_unsettled = !cleanup_paths(&self.paths); + RelayFinish { + failed, + cleanup_unsettled, + } + } +} + +fn remove_created_paths(directory: &Path, socket: &Path) -> RelayStartFailure { + let socket_removed = match fs::remove_file(socket) { + Ok(()) => true, + Err(error) => error.kind() == std::io::ErrorKind::NotFound, + }; + let directory_removed = match fs::remove_dir(directory) { + Ok(()) => true, + Err(error) => error.kind() == std::io::ErrorKind::NotFound, + }; + RelayStartFailure { + cleanup_unsettled: !socket_removed || !directory_removed, + } +} + +impl Drop for InvocationRelay { + fn drop(&mut self) { + self.cancelled.store(true, Ordering::Release); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + let _ = cleanup_paths(&self.paths); + } +} + +pub(crate) fn valid_model_grant(grant: &ModelGrant, request_digest: &str, phase: &str) -> bool { + phase == "post_submit" + && grant.binding_digest == request_digest + && grant.grant_id.len() == 38 + && grant.grant_id.starts_with("model_") + && grant.grant_id[6..] + .bytes() + .all(|item| item.is_ascii_hexdigit() && !item.is_ascii_uppercase()) +} + +pub(crate) fn validate_proxy_socket(path: &Path) -> Result<(), String> { + if !path.is_absolute() { + return Err("model proxy socket must be absolute".into()); + } + let parent = path + .parent() + .ok_or_else(|| "model proxy socket parent invalid".to_string())?; + let canonical_parent = + fs::canonicalize(parent).map_err(|_| "model proxy socket parent unavailable")?; + if canonical_parent != parent { + return Err("model proxy socket parent custody invalid".into()); + } + let parent_metadata = + fs::symlink_metadata(parent).map_err(|_| "model proxy socket parent unavailable")?; + if !private_process_directory(&parent_metadata) { + return Err("model proxy socket parent custody invalid".into()); + } + let metadata = fs::symlink_metadata(path).map_err(|_| "model proxy socket unavailable")?; + // SAFETY: effective IDs are read-only process attributes. + let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) }; + if metadata.file_type().is_symlink() + || !metadata.file_type().is_socket() + || metadata.mode() & 0o777 != 0o600 + || metadata.uid() != uid + || metadata.gid() != gid + { + return Err("model proxy socket invalid".into()); + } + Ok(()) +} + +pub(crate) fn validate_relay_root(path: &Path) -> Result<(), String> { + if !path.is_absolute() || path.as_os_str().as_bytes().contains(&b',') { + return Err("model relay root invalid".into()); + } + let canonical = fs::canonicalize(path).map_err(|_| "model relay root unavailable")?; + let metadata = fs::symlink_metadata(path).map_err(|_| "model relay root unavailable")?; + if canonical != path || !private_process_directory(&metadata) { + return Err("model relay root custody invalid".into()); + } + let maximum_socket_path = path + .join(format!("{INVOCATION_DIRECTORY_PREFIX}{}", "0".repeat(16))) + .join("model.sock"); + if maximum_socket_path.as_os_str().as_bytes().len() >= 108 { + return Err("model relay root path too long".into()); + } + Ok(()) +} + +fn create_invocation_directory(parent: &Path) -> Result { + for _ in 0..8 { + let mut random = [0_u8; 8]; + File::open("/dev/urandom") + .and_then(|mut source| source.read_exact(&mut random)) + .map_err(|_| "model relay randomness unavailable")?; + let name = random + .iter() + .fold(String::with_capacity(16), |mut value, byte| { + use std::fmt::Write; + write!(&mut value, "{byte:02x}").expect("writing to String cannot fail"); + value + }); + let path = parent.join(format!("{INVOCATION_DIRECTORY_PREFIX}{name}")); + let mut builder = DirBuilder::new(); + builder.mode(0o700); + match builder.create(&path) { + Ok(()) => return Ok(path), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(_) => return Err("model relay directory unavailable".into()), + } + } + Err("model relay directory collision".into()) +} + +fn relay_once( + listener: UnixListener, + proxy_path: &Path, + grant: &ModelGrant, + request_digest: &str, + deadline: Instant, + cancelled: &AtomicBool, +) -> Result<(), String> { + let mut image = loop { + if Instant::now() >= deadline { + return Err("model relay deadline exceeded".into()); + } + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if cancelled.load(Ordering::Acquire) { + return Ok(()); + } + thread::sleep(Duration::from_millis(2)); + } + Err(_) => return Err("model relay accept failed".into()), + } + }; + drop(listener); // one call slot: no second connection can be accepted. + image + .set_nonblocking(true) + .map_err(|_| "model relay image socket invalid")?; + let request = read_frame(&mut image, deadline, cancelled)?; + let image_request: ImageModelRequest = + serde_json::from_slice(&request).map_err(|_| "model relay request invalid")?; + let wrapped = serde_json::to_vec(&ProxyModelRequest { + protocol_version: MODEL_PROXY_PROTOCOL, + grant_id: &grant.grant_id, + request_digest, + prompt: &image_request.prompt, + }) + .map_err(|_| "model relay request invalid")?; + if wrapped.is_empty() || wrapped.len() > MODEL_FRAME_MAXIMUM { + return Err("model relay request unbounded".into()); + } + let mut proxy = connect_nonblocking(proxy_path, deadline, cancelled)?; + write_frame(&mut proxy, &wrapped, deadline, cancelled)?; + let response = read_frame(&mut proxy, deadline, cancelled)?; + let response_value: ProxyModelResponse = + serde_json::from_slice(&response).map_err(|_| "model relay response invalid")?; + if !valid_proxy_response(&response_value) { + return Err("model relay response invalid".into()); + } + write_frame(&mut image, &response, deadline, cancelled) +} + +fn valid_proxy_response(response: &ProxyModelResponse) -> bool { + if response.protocol_version != MODEL_PROXY_PROTOCOL { + return false; + } + match response.outcome.as_str() { + "completed" => { + response + .output + .as_ref() + .is_some_and(|value| !value.is_empty()) + && response.receipt_digest.as_deref().is_some_and(valid_sha256) + && response.failure_code.is_none() + } + "unavailable" => { + response.output.is_none() + && response.receipt_digest.is_none() + && response + .failure_code + .as_deref() + .is_some_and(valid_failure_code) + } + _ => false, + } +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 71 + && value.starts_with("sha256:") + && value[7..] + .bytes() + .all(|item| item.is_ascii_hexdigit() && !item.is_ascii_uppercase()) +} + +fn valid_failure_code(value: &str) -> bool { + (1..=64).contains(&value.len()) + && value.as_bytes()[0].is_ascii_lowercase() + && value + .bytes() + .all(|item| item.is_ascii_lowercase() || item.is_ascii_digit() || item == b'_') +} + +fn read_frame( + stream: &mut UnixStream, + deadline: Instant, + cancelled: &AtomicBool, +) -> Result, String> { + let mut header = [0_u8; 4]; + transfer(stream, &mut header, deadline, cancelled, |stream, bytes| { + stream.read(bytes) + })?; + let size = u32::from_be_bytes(header) as usize; + if size == 0 || size > MODEL_FRAME_MAXIMUM { + return Err("model relay frame unbounded".into()); + } + let mut body = vec![0_u8; size]; + transfer(stream, &mut body, deadline, cancelled, |stream, bytes| { + stream.read(bytes) + })?; + Ok(body) +} + +fn write_frame( + stream: &mut UnixStream, + body: &[u8], + deadline: Instant, + cancelled: &AtomicBool, +) -> Result<(), String> { + if body.is_empty() || body.len() > MODEL_FRAME_MAXIMUM { + return Err("model relay frame unbounded".into()); + } + let mut header = (body.len() as u32).to_be_bytes(); + transfer(stream, &mut header, deadline, cancelled, |stream, bytes| { + stream.write(bytes) + })?; + let mut body = body.to_vec(); + transfer(stream, &mut body, deadline, cancelled, |stream, bytes| { + stream.write(bytes) + }) +} + +fn transfer( + stream: &mut UnixStream, + bytes: &mut [u8], + deadline: Instant, + cancelled: &AtomicBool, + mut operation: impl FnMut(&mut UnixStream, &mut [u8]) -> std::io::Result, +) -> Result<(), String> { + let mut offset = 0; + while offset < bytes.len() { + if cancelled.load(Ordering::Acquire) { + return Err("model relay cancelled".into()); + } + if Instant::now() >= deadline { + return Err("model relay deadline exceeded".into()); + } + match operation(stream, &mut bytes[offset..]) { + Ok(0) => return Err("model relay frame incomplete".into()), + Ok(count) => offset += count, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(2)); + } + Err(_) => return Err("model relay I/O failed".into()), + } + } + Ok(()) +} + +fn connect_nonblocking( + path: &Path, + deadline: Instant, + cancelled: &AtomicBool, +) -> Result { + let bytes = path.as_os_str().as_bytes(); + if bytes.is_empty() || bytes.len() >= 108 || bytes.contains(&0) { + return Err("model proxy socket path invalid".into()); + } + // SAFETY: socket returns a new descriptor or -1 and has no borrowed inputs. + let descriptor = unsafe { + libc::socket( + libc::AF_UNIX, + libc::SOCK_STREAM | libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC, + 0, + ) + }; + if descriptor < 0 { + return Err("model proxy connection unavailable".into()); + } + // SAFETY: zero is a valid initialized representation for sockaddr_un. + let mut address: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + address.sun_family = libc::AF_UNIX as libc::sa_family_t; + for (target, source) in address.sun_path.iter_mut().zip(bytes) { + *target = *source as libc::c_char; + } + let length = (std::mem::size_of::() + bytes.len() + 1) as libc::socklen_t; + // SAFETY: descriptor is owned and address/length describe initialized storage. + let connected = unsafe { + libc::connect( + descriptor, + (&address as *const libc::sockaddr_un).cast(), + length, + ) + }; + if connected != 0 { + let error = std::io::Error::last_os_error(); + if !matches!( + error.raw_os_error(), + Some(code) if code == libc::EINPROGRESS || code == libc::EAGAIN + ) { + // SAFETY: descriptor is owned and not converted into a UnixStream. + unsafe { libc::close(descriptor) }; + return Err("model proxy connection unavailable".into()); + } + loop { + if cancelled.load(Ordering::Acquire) || Instant::now() >= deadline { + // SAFETY: descriptor is owned and not converted into a UnixStream. + unsafe { libc::close(descriptor) }; + return Err("model proxy connection deadline exceeded".into()); + } + let mut poll = libc::pollfd { + fd: descriptor, + events: libc::POLLOUT, + revents: 0, + }; + // SAFETY: poll points to one initialized pollfd for the supplied count. + let ready = unsafe { libc::poll(&mut poll, 1, 2) }; + if ready < 0 { + // SAFETY: descriptor is owned and not converted into a UnixStream. + unsafe { libc::close(descriptor) }; + return Err("model proxy connection unavailable".into()); + } + if ready == 0 { + continue; + } + let mut socket_error: libc::c_int = 0; + let mut size = std::mem::size_of_val(&socket_error) as libc::socklen_t; + // SAFETY: pointers refer to writable initialized storage with exact size. + let status = unsafe { + libc::getsockopt( + descriptor, + libc::SOL_SOCKET, + libc::SO_ERROR, + (&mut socket_error as *mut libc::c_int).cast(), + &mut size, + ) + }; + if status != 0 || socket_error != 0 { + // SAFETY: descriptor is owned and not converted into a UnixStream. + unsafe { libc::close(descriptor) }; + return Err("model proxy connection unavailable".into()); + } + break; + } + } + // SAFETY: descriptor is a connected owned AF_UNIX stream descriptor. + Ok(unsafe { UnixStream::from_raw_fd(descriptor) }) +} + +fn cleanup_paths(paths: &RelayPaths) -> bool { + let socket_ok = match fs::symlink_metadata(&paths.socket) { + Ok(metadata) + if sandbox_socket(&metadata, paths.sandbox_uid, paths.sandbox_gid) + && (metadata.dev(), metadata.ino()) + == (paths.socket_device, paths.socket_inode) => + { + fs::remove_file(&paths.socket).is_ok() + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + _ => false, + }; + let directory_ok = match fs::symlink_metadata(&paths.directory) { + Ok(metadata) + if private_process_directory(&metadata) + && (metadata.dev(), metadata.ino()) + == (paths.directory_device, paths.directory_inode) => + { + fs::remove_dir(&paths.directory).is_ok() + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + _ => false, + }; + socket_ok && directory_ok +} + +fn private_process_directory(metadata: &fs::Metadata) -> bool { + // SAFETY: effective IDs are read-only process attributes. + let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) }; + metadata.file_type().is_dir() + && metadata.mode() & 0o777 == 0o700 + && metadata.uid() == uid + && metadata.gid() == gid +} + +fn sandbox_socket(metadata: &fs::Metadata, uid: u32, gid: u32) -> bool { + metadata.file_type().is_socket() + && metadata.mode() & 0o777 == 0o600 + && metadata.uid() == uid + && metadata.gid() == gid +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicU64; + use std::time::{SystemTime, UNIX_EPOCH}; + + static NEXT_ROOT: AtomicU64 = AtomicU64::new(0); + + fn private_root() -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "ws-model-relay-{}-{unique}-{}", + std::process::id(), + NEXT_ROOT.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + root + } + + fn frame(stream: &mut UnixStream, value: &[u8]) { + stream + .write_all(&(value.len() as u32).to_be_bytes()) + .unwrap(); + stream.write_all(value).unwrap(); + } + + fn receive(stream: &mut UnixStream) -> Vec { + stream.set_nonblocking(false).unwrap(); + let mut header = [0_u8; 4]; + stream.read_exact(&mut header).unwrap(); + let mut body = vec![0; u32::from_be_bytes(header) as usize]; + stream.read_exact(&mut body).unwrap(); + body + } + + #[test] + fn grant_is_exactly_post_submit_and_request_bound() { + let digest = format!("sha256:{}", "a".repeat(64)); + let grant = ModelGrant { + grant_id: format!("model_{}", "1".repeat(32)), + binding_digest: digest.clone(), + }; + assert!(valid_model_grant(&grant, &digest, "post_submit")); + assert!(!valid_model_grant(&grant, &digest, "pre_submit")); + assert!(!valid_model_grant( + &grant, + &format!("sha256:{}", "b".repeat(64)), + "post_submit" + )); + for invalid in [ + "model_0123456789abcdef0123456789abcde", + "model_0123456789abcdef0123456789abcdef0", + "model_0123456789abcdef0123456789abcdeG", + "other_0123456789abcdef0123456789abcdef", + ] { + let mut changed = grant.clone(); + changed.grant_id = invalid.into(); + assert!(!valid_model_grant(&changed, &digest, "post_submit")); + } + } + + #[test] + fn diagnostic_output_redacts_model_grants_prompts_and_results() { + let grant_id = format!("model_{}", "1".repeat(32)); + let digest = format!("sha256:{}", "a".repeat(64)); + let prompt = "private image prompt"; + let output = "private model output"; + let grant = ModelGrant { + grant_id: grant_id.clone(), + binding_digest: digest.clone(), + }; + let required = RequiredModelGrant(Some(grant.clone())); + let image = ImageModelRequest { + prompt: prompt.into(), + }; + let request = ProxyModelRequest { + protocol_version: MODEL_PROXY_PROTOCOL, + grant_id: &grant.grant_id, + request_digest: &digest, + prompt, + }; + let response = ProxyModelResponse { + protocol_version: MODEL_PROXY_PROTOCOL.into(), + outcome: "completed".into(), + output: Some(output.into()), + receipt_digest: Some(format!("sha256:{}", "b".repeat(64))), + failure_code: None, + }; + + for diagnostic in [ + format!("{grant:?}"), + format!("{required:?}"), + format!("{image:?}"), + format!("{request:?}"), + format!("{response:?}"), + ] { + assert!(!diagnostic.contains(&grant_id)); + assert!(!diagnostic.contains(&digest)); + assert!(!diagnostic.contains(prompt)); + assert!(!diagnostic.contains(output)); + } + assert_eq!( + format!("{:?}", RequiredModelGrant(None)), + "RequiredModelGrant(None)" + ); + } + + #[test] + fn proxy_response_has_a_closed_non_provider_contract() { + let completed = ProxyModelResponse { + protocol_version: MODEL_PROXY_PROTOCOL.into(), + outcome: "completed".into(), + output: Some("answer".into()), + receipt_digest: Some(format!("sha256:{}", "a".repeat(64))), + failure_code: None, + }; + assert!(valid_proxy_response(&completed)); + let unavailable = ProxyModelResponse { + protocol_version: MODEL_PROXY_PROTOCOL.into(), + outcome: "unavailable".into(), + output: None, + receipt_digest: None, + failure_code: Some("budget_unavailable".into()), + }; + assert!(valid_proxy_response(&unavailable)); + for invalid in [ + ProxyModelResponse { + failure_code: Some("provider_error: secret".into()), + ..unavailable.clone() + }, + ProxyModelResponse { + receipt_digest: None, + ..completed.clone() + }, + ] { + assert!(!valid_proxy_response(&invalid)); + } + assert!(serde_json::from_slice::( + br#"{"protocol_version":"workstream.checker_model_proxy.v1","outcome":"unavailable","output":null,"receipt_digest":null,"failure_code":"provider_unavailable","provider_id":"secret"}"# + ) + .is_err()); + assert!(serde_json::from_slice::( + br#"{"protocol_version":"workstream.checker_model_proxy.v1","outcome":"unavailable","output":null,"failure_code":"provider_unavailable"}"# + ) + .is_err()); + } + + #[test] + fn relay_wraps_one_exchange_and_cleans_invocation_socket() { + let root = private_root(); + let proxy_path = root.join("proxy.sock"); + let proxy = UnixListener::bind(&proxy_path).unwrap(); + fs::set_permissions(&proxy_path, fs::Permissions::from_mode(0o600)).unwrap(); + let digest = format!("sha256:{}", "a".repeat(64)); + let grant = ModelGrant { + grant_id: format!("model_{}", "1".repeat(32)), + binding_digest: digest.clone(), + }; + let uid = fs::metadata(&root).unwrap().uid(); + let gid = fs::metadata(&root).unwrap().gid(); + let relay = InvocationRelay::start( + &root, + &proxy_path, + &grant, + &digest, + uid, + gid, + Instant::now() + Duration::from_secs(2), + ) + .unwrap(); + let relay_path = relay.socket_path().to_owned(); + let mut image = UnixStream::connect(&relay_path).unwrap(); + frame(&mut image, br#"{"prompt":"review this"}"#); + let (mut backend, _) = proxy.accept().unwrap(); + let wrapped: serde_json::Value = serde_json::from_slice(&receive(&mut backend)).unwrap(); + assert_eq!(wrapped["protocol_version"], MODEL_PROXY_PROTOCOL); + assert_eq!(wrapped["grant_id"], grant.grant_id); + assert_eq!(wrapped["request_digest"], digest); + assert_eq!(wrapped["prompt"], "review this"); + let response = format!( + r#"{{"protocol_version":"{MODEL_PROXY_PROTOCOL}","outcome":"completed","output":"bounded","receipt_digest":"sha256:{}","failure_code":null}}"#, + "b".repeat(64) + ); + frame(&mut backend, response.as_bytes()); + assert_eq!(receive(&mut image), response.as_bytes()); + assert!(UnixStream::connect(&relay_path).is_err()); + assert_eq!( + relay.finish(), + RelayFinish { + failed: false, + cleanup_unsettled: false + } + ); + assert!(!relay_path.exists()); + fs::remove_file(proxy_path).unwrap(); + fs::remove_dir(root).unwrap(); + } + + #[test] + fn distinct_invocations_and_frame_bounds_do_not_cross() { + let root = private_root(); + let proxy_path = root.join("proxy.sock"); + let _proxy = UnixListener::bind(&proxy_path).unwrap(); + fs::set_permissions(&proxy_path, fs::Permissions::from_mode(0o600)).unwrap(); + let uid = fs::metadata(&root).unwrap().uid(); + let gid = fs::metadata(&root).unwrap().gid(); + let digest = format!("sha256:{}", "a".repeat(64)); + let grant = ModelGrant { + grant_id: format!("model_{}", "1".repeat(32)), + binding_digest: digest.clone(), + }; + let first = InvocationRelay::start( + &root, + &proxy_path, + &grant, + &digest, + uid, + gid, + Instant::now() + Duration::from_millis(100), + ) + .unwrap(); + let second = InvocationRelay::start( + &root, + &proxy_path, + &grant, + &digest, + uid, + gid, + Instant::now() + Duration::from_millis(100), + ) + .unwrap(); + assert_ne!(first.socket_path(), second.socket_path()); + let mut attacker = UnixStream::connect(first.socket_path()).unwrap(); + attacker + .write_all(&((MODEL_FRAME_MAXIMUM + 1) as u32).to_be_bytes()) + .unwrap(); + assert!(first.finish().failed); + assert!(!second.finish().cleanup_unsettled); + fs::remove_file(proxy_path).unwrap(); + fs::remove_dir(root).unwrap(); + } + + #[test] + fn incomplete_exchange_obeys_deadline_and_cleanup() { + let root = private_root(); + let proxy_path = root.join("proxy.sock"); + let _proxy = UnixListener::bind(&proxy_path).unwrap(); + fs::set_permissions(&proxy_path, fs::Permissions::from_mode(0o600)).unwrap(); + let uid = fs::metadata(&root).unwrap().uid(); + let gid = fs::metadata(&root).unwrap().gid(); + let digest = format!("sha256:{}", "a".repeat(64)); + let grant = ModelGrant { + grant_id: format!("model_{}", "1".repeat(32)), + binding_digest: digest.clone(), + }; + let started = Instant::now(); + let relay = InvocationRelay::start( + &root, + &proxy_path, + &grant, + &digest, + uid, + gid, + Instant::now() + Duration::from_millis(50), + ) + .unwrap(); + let mut image = UnixStream::connect(relay.socket_path()).unwrap(); + image.write_all(&20_u32.to_be_bytes()).unwrap(); + assert!(relay.finish().failed); + assert!(started.elapsed() < Duration::from_millis(500)); + fs::remove_file(proxy_path).unwrap(); + fs::remove_dir(root).unwrap(); + } + + #[test] + fn relay_rejects_a_non_object_proxy_response() { + let root = private_root(); + let proxy_path = root.join("proxy.sock"); + let proxy = UnixListener::bind(&proxy_path).unwrap(); + fs::set_permissions(&proxy_path, fs::Permissions::from_mode(0o600)).unwrap(); + let owner = fs::metadata(&root).unwrap(); + let digest = format!("sha256:{}", "a".repeat(64)); + let grant = ModelGrant { + grant_id: format!("model_{}", "1".repeat(32)), + binding_digest: digest.clone(), + }; + let relay = InvocationRelay::start( + &root, + &proxy_path, + &grant, + &digest, + owner.uid(), + owner.gid(), + Instant::now() + Duration::from_secs(2), + ) + .unwrap(); + let mut image = UnixStream::connect(relay.socket_path()).unwrap(); + frame(&mut image, br#"{"prompt":"review this"}"#); + let (mut backend, _) = proxy.accept().unwrap(); + let _ = receive(&mut backend); + frame(&mut backend, br#"["not","a","contract"]"#); + assert!(relay.finish().failed); + fs::remove_file(proxy_path).unwrap(); + fs::remove_dir(root).unwrap(); + } + + #[test] + fn proxy_path_must_be_an_absolute_socket() { + let root = private_root(); + assert!(validate_relay_root(&root).is_ok()); + assert!(validate_proxy_socket(Path::new("relative.sock")).is_err()); + let regular = root.join("regular"); + fs::write(®ular, b"not a socket").unwrap(); + assert!(validate_proxy_socket(®ular).is_err()); + let socket = root.join("proxy.sock"); + let _listener = UnixListener::bind(&socket).unwrap(); + assert!(validate_proxy_socket(&socket).is_err()); + fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).unwrap(); + assert!(validate_proxy_socket(&socket).is_ok()); + let trusted = root.join("trusted"); + fs::create_dir(&trusted).unwrap(); + fs::set_permissions(&trusted, fs::Permissions::from_mode(0o700)).unwrap(); + let linked = root.join("linked"); + std::os::unix::fs::symlink(&trusted, &linked).unwrap(); + let linked_socket = trusted.join("proxy.sock"); + let _linked_listener = UnixListener::bind(&linked_socket).unwrap(); + fs::set_permissions(&linked_socket, fs::Permissions::from_mode(0o600)).unwrap(); + assert!(validate_proxy_socket(&linked.join("proxy.sock")).is_err()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn relay_root_is_private_bounded_and_does_not_need_chown() { + let root = private_root(); + let proxy_path = root.join("proxy.sock"); + let _proxy = UnixListener::bind(&proxy_path).unwrap(); + fs::set_permissions(&proxy_path, fs::Permissions::from_mode(0o600)).unwrap(); + let owner = fs::metadata(&root).unwrap(); + let digest = format!("sha256:{}", "a".repeat(64)); + let grant = ModelGrant { + grant_id: format!("model_{}", "1".repeat(32)), + binding_digest: digest.clone(), + }; + let different_uid = owner.uid().wrapping_add(1); + assert!(InvocationRelay::start( + &root, + &proxy_path, + &grant, + &digest, + different_uid, + owner.gid(), + Instant::now() + Duration::from_secs(1), + ) + .is_err()); + assert_eq!( + fs::read_dir(&root) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with(".model-")) + .count(), + 0 + ); + + let comma_root = root.join("relay,invalid"); + fs::create_dir(&comma_root).unwrap(); + fs::set_permissions(&comma_root, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(validate_relay_root(&comma_root).is_err()); + + let long_root = root.join("x".repeat(100)); + fs::create_dir(&long_root).unwrap(); + fs::set_permissions(&long_root, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(validate_relay_root(&long_root).is_err()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/mcp_server/contracts/permissions_list.json b/mcp_server/contracts/permissions_list.json index 7dc5bd378..7e751040b 100644 --- a/mcp_server/contracts/permissions_list.json +++ b/mcp_server/contracts/permissions_list.json @@ -1,5 +1,5 @@ { - "canonical_sha256": "ce2d08c29f50493ccdb80798db1f65c9690dcd614304c8bdbef80a12c2268e37", + "canonical_sha256": "4ebd90f7658f1d67b402a4818d718d71cb4b3ab68042466a5844b345869de20c", "components": { "schemas": { "ApiError": { @@ -87,7 +87,7 @@ "type": "array" }, "total": { - "const": 82, + "const": 91, "title": "Total", "type": "integer" } @@ -119,6 +119,15 @@ "checker.pre_submit.finalize", "checker.post_submit.execute", "checker.post_submit.finalize", + "checker.model_call.prepare", + "checker.model_call.dispatch", + "checker.model_call.recover", + "checker.model_call.observe", + "checker.model_call.complete", + "checker.model_call.grant", + "project.model_profile.manage", + "project.model_budget.manage", + "project.model_call.recovery_child.authorize", "outbox.dispatch", "actor.service.provision", "admin_role.read", diff --git a/mcp_server/tests/integration/test_access_reads.py b/mcp_server/tests/integration/test_access_reads.py index 8d89d44b6..fafb1a6b7 100644 --- a/mcp_server/tests/integration/test_access_reads.py +++ b/mcp_server/tests/integration/test_access_reads.py @@ -202,7 +202,7 @@ async def _exercise_access_reads( if allowed and "items" in body: assert body["items"] and body["total"] >= len(body["items"]) if allowed and name == "workstream_permissions_list": - assert body["total"] == len(body["items"]) == 82 + assert body["total"] == len(body["items"]) == 91 assert {"task.revision.expire", "task.revision.recover"} <= { item["permission_id"] for item in body["items"] } diff --git a/mcp_server/tests/test_access_reads.py b/mcp_server/tests/test_access_reads.py index 24415e98d..ec1e524d3 100644 --- a/mcp_server/tests/test_access_reads.py +++ b/mcp_server/tests/test_access_reads.py @@ -52,7 +52,7 @@ def _arguments(name: str) -> dict[str, Any]: def _response(name: str) -> dict[str, Any]: # Representative entries use the backend's frozen catalogue totals. if name == "workstream_permissions_list": - return {"items": [{"permission_id": "actor.profile.read_any"}], "total": 82} + return {"items": [{"permission_id": "actor.profile.read_any"}], "total": 91} if name == "workstream_admin_roles_list": return { "items": [ @@ -219,7 +219,7 @@ def test_permission_catalogue_drift_still_fails_closed( {"permission_id": "task.revision.expire"}, {"permission_id": "task.revision.recover"}, ], - "total": 82, + "total": 91, } if malformation == "unknown_permission": payload["items"][0]["permission_id"] = "task.revision.unknown" From 63614e145d0f10e9f3a4f1e745b585aa98248507 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 06:01:28 +0100 Subject: [PATCH 004/115] [PILOT-05] harden model-call custody and recovery --- .ci/auth-boundaries/TEST_STRUCTURE_DEBT.json | 20 +- .ci/behavior-ownership/partition.v1.json | 94 ++- .commitrail/changes/checker-model-runtime.md | 60 +- .../versions/0059_checker_model_runtime.py | 163 +++- backend/app/adapters/artifacts/__init__.py | 175 +++- backend/app/adapters/checkers/__init__.py | 46 +- .../app/adapters/checkers/model_provider.py | 39 +- .../checkers/model_proxy.py} | 7 +- .../app/adapters/checkers/model_runtime.py | 26 +- backend/app/api/deps/checker_models.py | 40 +- backend/app/db/models.py | 10 +- .../artifacts/api/checker_output_authority.py | 76 ++ .../artifacts/checker_output_bindings.py | 28 +- .../artifacts/checker_output_custody.py | 18 +- .../app/modules/artifacts/checker_outputs.py | 31 +- backend/app/modules/artifacts/schemas.py | 6 - backend/app/modules/artifacts/service.py | 18 +- .../app/modules/authorization/catalogue.py | 270 +----- .../checker_model_authorization.py | 22 +- .../checker_output_authorization.py | 39 +- .../authorization/domain/audit_targets.py | 14 +- .../authorization/domain/checker_models.py | 14 +- .../authorization/domain/checker_outputs.py | 14 +- .../domain/prepared_checker_runtime.py | 65 ++ backend/app/modules/authorization/kernel.py | 26 +- backend/app/modules/authorization/prepared.py | 73 +- backend/app/modules/checkers/api/execution.py | 2 + .../app/modules/checkers/api/model_calls.py | 162 +++- backend/app/modules/checkers/checker_retry.py | 8 + .../modules/checkers/model_calls/custody.py | 104 ++- .../modules/checkers/model_calls/grants.py | 68 +- .../modules/checkers/model_calls/models.py | 57 ++ .../modules/checkers/model_calls/profiles.py | 2 + .../checkers/model_calls/repository.py | 35 + .../modules/checkers/model_calls/service.py | 182 +++- backend/app/modules/checkers/router.py | 8 +- backend/scripts/behavior_ownership.py | 28 + .../checker_models/test_contracts.py | 20 + .../tests/checker_output_admission_helpers.py | 5 +- .../tests/checker_output_custody_helpers.py | 357 +++++++- .../execution/test_checker_retry_authority.py | 3 +- .../external/test_harnessy_proof_checker.py | 19 +- .../model_provider/test_openai_responses.py | 2 +- .../test_composed_execution.py | 192 ++++- ...test_model_call_transactions_postgresql.py | 146 +++- .../test_model_runtime.py | 53 ++ .../test_proxy_orchestration.py | 47 +- .../checkers_model_calls/test_public_api.py | 246 +++++- backend/tests/conftest.py | 2 +- .../post_submit_materialization_helpers.py | 14 +- .../guide_activation/source_fixtures.py | 7 +- .../proposals/test_postgresql.py | 2 + .../tests/projects/unified_policy_fixtures.py | 2 + backend/tests/selected_policy_fixtures.py | 2 + backend/tests/test_alembic.py | 1 + backend/tests/test_artifact_architecture.py | 26 +- backend/tests/test_checker_output_custody.py | 104 ++- cli/internal/api/checker_models.go | 65 ++ cli/internal/api/checker_operations.go | 18 +- .../test_checker_operations_http.py | 52 +- docker-compose.agent-loop.yml | 14 +- docker-compose.yml | 13 + docs/architecture_checker_framework.md | 21 +- docs/architecture_data_model.md | 33 + docs/operations_checker_models.md | 45 +- docs/operations_roles_permissions.md | 3 + docs/roadmap_status.md | 19 +- docs/spec_authorization_service.md | 36 +- external_checkers/README.md | 38 +- .../tests/model_relay_docker.py | 791 ++++++++++++++++++ mcp_server/contracts/admin_roles_list.json | 13 +- .../contracts/authorization_context_get.json | 17 +- mcp_server/contracts/permissions_list.json | 2 +- pilots/terminal_systems/README.md | 5 +- pilots/terminal_systems/checkers/README.md | 32 +- 75 files changed, 3665 insertions(+), 852 deletions(-) rename backend/app/{modules/checkers/model_calls/proxy.py => adapters/checkers/model_proxy.py} (97%) create mode 100644 backend/app/modules/artifacts/api/checker_output_authority.py create mode 100644 backend/app/modules/authorization/domain/prepared_checker_runtime.py create mode 100644 cli/internal/api/checker_models.go create mode 100644 external_checkers/workstream-checker-service/tests/model_relay_docker.py diff --git a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json index 4712a6829..4b0f44333 100644 --- a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json +++ b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json @@ -2,11 +2,11 @@ "entries": [ { "capability": "oversized_authorization_structure", - "content_sha256": "52925f333aa6dec0a6355e76c8352fbf7f7fff4d6de898a98834b4d13f15c393", - "end_line": 1270, + "content_sha256": "9bd965c903b2b2586248f127f9b1e991bb5871659d624b5f69f3b4a2b2499329", + "end_line": 1269, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1270, + "observed_lines": 1269, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -27,26 +27,26 @@ { "capability": "oversized_authorization_structure", "content_sha256": "75bcb2639cd1f099597c8b07efa9b6d78d334ea3f092a1bf89ef638417b5856f", - "end_line": 494, + "end_line": 488, "hard_limit": 100, "kind": "production_function", "observed_lines": 101, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._prepare_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 394 + "start_line": 388 }, { "capability": "oversized_authorization_structure", "content_sha256": "a2bcc3b6fbfb59e65e5c606b777378451733f5fd11c9aeccf9f2e05773b2361f", - "end_line": 818, + "end_line": 812, "hard_limit": 100, "kind": "production_function", "observed_lines": 106, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._require_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 713 + "start_line": 707 }, { "capability": "oversized_authorization_structure", @@ -86,11 +86,11 @@ }, { "capability": "oversized_authorization_structure", - "content_sha256": "c20ff97f3860c899f5395fb3341123e7ec5302468ea7cef63e5f87672791e419", - "end_line": 2467, + "content_sha256": "367dcecc045d7db91ffe36e4164052e479d484cd26cb7b424a96155d8e7f529b", + "end_line": 2453, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 2467, + "observed_lines": 2453, "path": "backend/tests/test_artifact_admission.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index fd25c5d5c..2be5e45eb 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -52,6 +52,22 @@ "group": "artifacts", "target": "backend/app/adapters/checkers/external_service.py" }, + { + "group": "artifacts", + "target": "backend/app/adapters/checkers/model_execution.py" + }, + { + "group": "artifacts", + "target": "backend/app/adapters/checkers/model_provider.py" + }, + { + "group": "artifacts", + "target": "backend/app/adapters/checkers/model_proxy_transport.py" + }, + { + "group": "artifacts", + "target": "backend/app/adapters/checkers/model_runtime.py" + }, { "group": "shared", "target": "backend/app/adapters/observability.py" @@ -104,6 +120,10 @@ "group": "shared", "target": "backend/app/api/deps/authorization.py" }, + { + "group": "artifacts", + "target": "backend/app/api/deps/checker_models.py" + }, { "group": "artifacts", "target": "backend/app/api/deps/checker_recovery.py" @@ -316,6 +336,14 @@ "group": "auth", "target": "backend/app/interfaces/auth.py" }, + { + "group": "artifacts", + "target": "backend/app/interfaces/checker_model_provider.py" + }, + { + "group": "artifacts", + "target": "backend/app/interfaces/checker_model_proxy.py" + }, { "group": "artifacts", "target": "backend/app/interfaces/external_checker_execution.py" @@ -416,6 +444,10 @@ "group": "artifacts", "target": "backend/app/modules/artifacts/authorization.py" }, + { + "group": "artifacts", + "target": "backend/app/modules/artifacts/checker_model_evidence.py" + }, { "group": "artifacts", "target": "backend/app/modules/artifacts/checker_output_bindings.py" @@ -664,6 +696,14 @@ "group": "auth", "target": "backend/app/modules/authorization/catalogue.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/checker_model_authorization.py" + }, + { + "group": "auth", + "target": "backend/app/modules/authorization/checker_output_authorization.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/checker_registry_authorization.py" @@ -716,6 +756,14 @@ "group": "auth", "target": "backend/app/modules/authorization/domain/audit_targets.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/checker_models.py" + }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/checker_outputs.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/domain/checker_registry.py" @@ -1108,6 +1156,18 @@ "group": "artifacts", "target": "backend/app/modules/checkers/api/materialization.py" }, + { + "group": "artifacts", + "target": "backend/app/modules/checkers/api/model_calls.py" + }, + { + "group": "artifacts", + "target": "backend/app/modules/checkers/api/model_grants.py" + }, + { + "group": "artifacts", + "target": "backend/app/modules/checkers/api/model_profiles.py" + }, { "group": "artifacts", "target": "backend/app/modules/checkers/api/output_custody.py" @@ -1172,6 +1232,34 @@ "group": "artifacts", "target": "backend/app/modules/checkers/history.py" }, + { + "group": "artifacts", + "target": "backend/app/modules/checkers/model_calls/custody.py" + }, + { + "group": "artifacts", + "target": "backend/app/modules/checkers/model_calls/grants.py" + }, + { + "group": "artifacts", + "target": "backend/app/modules/checkers/model_calls/models.py" + }, + { + "group": "artifacts", + "target": "backend/app/modules/checkers/model_calls/profiles.py" + }, + { + "group": "artifacts", + "target": "backend/app/modules/checkers/model_calls/proxy.py" + }, + { + "group": "artifacts", + "target": "backend/app/modules/checkers/model_calls/repository.py" + }, + { + "group": "artifacts", + "target": "backend/app/modules/checkers/model_calls/service.py" + }, { "group": "artifacts", "target": "backend/app/modules/checkers/models.py" @@ -2124,6 +2212,10 @@ "group": "shared", "target": "backend/app/workers/celery_app.py" }, + { + "group": "artifacts", + "target": "backend/app/workers/checker_model_proxy.py" + }, { "group": "shared", "target": "backend/app/workers/errors.py" @@ -2293,7 +2385,7 @@ "target": "backend/scripts/validate_test_lane_evidence.py" } ], - "authority_digest": "7add897051f9ac8108873148d449eb71741309349320efd65ce542adaf880ede", + "authority_digest": "6f933bd4c8f9205db13e5656efd967d08597d2b11d0dbb1950139662e205b493", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.commitrail/changes/checker-model-runtime.md b/.commitrail/changes/checker-model-runtime.md index 7efb6dc51..3495ed748 100644 --- a/.commitrail/changes/checker-model-runtime.md +++ b/.commitrail/changes/checker-model-runtime.md @@ -39,6 +39,8 @@ ports, but their production reservation path needs inspection before reuse. network denial, read-only submission material, cleanup and output budgets. - Focused PostgreSQL/MinIO, transport, provider and public lifecycle tests; affected CI allocation, owner specifications, runbook and roadmap. +- Reconcile the existing CLI retry-source decoder and its process tests with + the bounded model recovery candidates returned by that public read. ### Not allowed @@ -177,7 +179,63 @@ honest separation of local proof from deployed operation. Initial source inspection and architecture review identified the stable paid slot and the need to preserve image ownership. This is a design record; no -runtime, budget, provider call or live project completion is established yet. +live provider spend or project completion is established yet. The implementation +has focused offline provider, proxy crash/replay, private Unix transport and +Compose lifecycle proof. The retained-data 0058-to-0059 upgrade and PostgreSQL +ledger, signed public provisioning and real MCP catalogue adapter checks pass. +All six composed public profile-to-checker cases pass with real PostgreSQL, +local/MinIO ART and a controlled provider/image boundary: completion, retained +paid results after a lost image reply, concurrent callbacks, and explicit child +recovery after proven no-effect or fully settled provider failure. Database and +MinIO cleanup completed. Final independent reviews and full CI remain required +before readiness. These checks do not establish live provider or gVisor operation. + +Implementation review caught and repaired two runtime defects: inherited Compose +profiles unintentionally started the optional listener, and an empty prompt could +reserve budget before local validation rejected it. Profile publication now also +requires the exact registered POST image digest, with independent SQL checks of +canonical profile/budget specification hashes. Regressions cover each boundary; +the final frozen review must assess their composed behavior and current head. + +The retained-data 0058-to-0059 upgrade exposed a PL/pgSQL parsing failure that +fresh baseline installation did not expose. A discriminating PostgreSQL probe +isolated the unparenthesized CASE expression in the ART binding guard; the +corrected upgrade passes with retained data and owned-database cleanup. Public +HTTP qualification also exposed strict JSON UUID decoding and a prepared +system-Operator scope mismatch. These require the real adapter/API proofs; the +ledger's controlled-authority tests alone cannot establish public usability. + +Composed execution also exposed strict Python decoding of the JSON prepared +ART authorization packet. The parser now validates that packet as JSON while +retaining strict integer validation. Both write and binding regressions reject +the original decoder. The composed fixture explicitly declares model evaluation +and supplies the canonical profile reader instead of attaching a model profile +to a static requirement; production policy validation remains unchanged. + +That composed path also exposed an inherited ART receipt trigger that required +a checker-run producer even for a paid-call receipt. The migration now admits +only the exact model-call attempt and request/response role while retaining the +existing producer guards. Fresh schema installation, the canonical ART custody +case and retained 0058-to-0059 upgrade pass with the corrected trigger. + +Canonical storage regressions also require malformed or substituted commitment +facts to leave AUTH through its concealed unavailable outcome. Resource +validation now discards the prepared handle before returning that outcome; +changed bytes cannot reach provider I/O. The remaining ART cases cover mixed +binding lineage, rollback/concurrent replay, uncertain puts, revocation, +cancellation and size limits. The adapter composition uses existing owner ports +and passes the protected-base boundary check without adding private-edge debt. + +Operator recovery uses the existing authorized checker retry-source read to +discover bounded eligible paid-call candidates. Child preparation retains exact +current Task/Submission custody without requiring a live worker lease; actual +dispatch still requires a fresh current invocation. This closes the otherwise +unusable repair path after a terminal infrastructure failure. + +Current owner documentation replaces deny-only generated-output wording with +active paid-call ART custody, lists all nine model permission/action mappings and +describes stable effect records. Existing normalized checker outcomes and +true/false review-policy routing remain unchanged. Plan review found that existing ART output slots are empty and run/lease-bound; the stable call evidence reservation above is required. It also required exact diff --git a/backend/alembic/versions/0059_checker_model_runtime.py b/backend/alembic/versions/0059_checker_model_runtime.py index ad253f947..99522b4d2 100644 --- a/backend/alembic/versions/0059_checker_model_runtime.py +++ b/backend/alembic/versions/0059_checker_model_runtime.py @@ -201,7 +201,7 @@ def _upgrade_invocation_grants() -> None: authority.action_id IS DISTINCT FROM 'checker.model_call.grant' OR authority.permission_id IS DISTINCT FROM 'checker.model_call.grant' OR authority.denial_code IS NOT NULL OR - authority.after_facts IS DISTINCT FROM jsonb_build_object( + authority.after_facts::jsonb IS DISTINCT FROM jsonb_build_object( 'allowed',true, 'resource_context_digest',authority.after_facts->>'resource_context_digest') OR coalesce(authority.after_facts->>'resource_context_digest','') @@ -366,6 +366,54 @@ def _upgrade_art_custody() -> None: ) op.execute( r""" +CREATE OR REPLACE FUNCTION public.guard_artifact_receipt_producer_reference() RETURNS trigger + LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ + DECLARE + attempt artifact_put_attempts%ROWTYPE; + BEGIN + SELECT value.* INTO attempt + FROM public.artifact_put_attempts value + WHERE value.id=NEW.put_attempt_id; + IF attempt.id IS NULL + OR (attempt.producer_request_type='guide' AND NOT ( + NEW.guide_source_item_id IS NOT NULL + AND NEW.checker_run_id IS NULL + AND NEW.model_call_attempt_id IS NULL + AND NEW.logical_role IS NULL)) + OR (attempt.producer_request_type='checker_output' AND NOT ( + NEW.guide_source_item_id IS NULL + AND ( + (attempt.model_call_attempt_id IS NULL + AND NEW.checker_run_id IS NOT NULL + AND NEW.model_call_attempt_id IS NULL + AND octet_length(NEW.logical_role) BETWEEN 1 AND 100) + OR + (attempt.model_call_attempt_id IS NOT NULL + AND NEW.checker_run_id IS NULL + AND NEW.model_call_attempt_id IS NOT DISTINCT FROM attempt.model_call_attempt_id + AND NEW.logical_role IS NOT DISTINCT FROM attempt.logical_role + AND NEW.logical_role IN ('model_request','model_response'))))) + OR (attempt.producer_request_type='submission_bundle' AND NOT ( + NEW.guide_source_item_id IS NULL + AND NEW.checker_run_id IS NULL + AND NEW.model_call_attempt_id IS NULL + AND NEW.logical_role IS NULL)) + OR (attempt.producer_request_type='task_import_source' AND ( + NEW.guide_source_item_id IS NOT NULL + OR NEW.checker_run_id IS NOT NULL + OR NEW.model_call_attempt_id IS NOT NULL + OR NEW.logical_role IS DISTINCT FROM 'task_import_source')) + OR attempt.producer_request_type NOT IN ( + 'guide','checker_output','submission_bundle','task_import_source') THEN + RAISE EXCEPTION 'artifact receipt producer reference mismatch' + USING ERRCODE='23514'; + END IF; + RETURN NEW; + END $$ + """ + ) + op.execute( + r""" ALTER TABLE artifact_bindings DROP CONSTRAINT ck_artifact_bindings_checker_output_lineage """ ) @@ -602,8 +650,10 @@ def _upgrade_art_custody() -> None: OR NEW.put_attempt_id IS DISTINCT FROM attempt.id OR NEW.content_id IS DISTINCT FROM content.id OR NEW.project_id IS DISTINCT FROM attempt.project_id - OR NEW.resource_id IS DISTINCT FROM CASE WHEN NEW.resource_type='checker_model_call' + OR NEW.resource_id IS DISTINCT FROM ( + CASE WHEN NEW.resource_type='checker_model_call' THEN attempt.model_call_attempt_id::text ELSE attempt.checker_run_id::text END + ) OR NEW.logical_role IS DISTINCT FROM attempt.logical_role OR NEW.actor_id IS DISTINCT FROM attempt.producer_ref OR NEW.attribution_type IS DISTINCT FROM 'service_identity' @@ -715,18 +765,38 @@ def _upgrade_art_custody() -> None: def _upgrade_authorization_vocabulary() -> None: - """Admit exactly the new model actions in the retained audit evidence constraint.""" + """Admit exactly the new model actions and permissions in retained audit custody.""" op.execute("LOCK TABLE public.audit_events IN ACCESS EXCLUSIVE MODE") name = "ck_audit_events_authorization_action_evidence" - definition = op.get_bind().execute(text( - "SELECT pg_catalog.pg_get_constraintdef(oid) FROM pg_catalog.pg_constraint " - "WHERE conrelid='public.audit_events'::regclass AND conname=:name" - ), {"name": name}).scalar_one() + definition = ( + op.get_bind() + .execute( + text( + "SELECT pg_catalog.pg_get_constraintdef(oid) FROM pg_catalog.pg_constraint " + "WHERE conrelid='public.audit_events'::regclass AND conname=:name" + ), + {"name": name}, + ) + .scalar_one() + ) anchor = ( "(((action_id)::text = 'contribution.compensation_unit.create'::text) AND " "((permission_id)::text = 'compensation.policy.manage'::text))" ) - actions = (('checker.model_call.prepare', 'checker.model_call.prepare'), ('checker.model_call.dispatch', 'checker.model_call.dispatch'), ('checker.model_call.recover', 'checker.model_call.recover'), ('checker.model_call.observe', 'checker.model_call.observe'), ('checker.model_call.complete', 'checker.model_call.complete'), ('checker.model_call.grant', 'checker.model_call.grant'), ('project.model_profile.provision', 'project.model_profile.manage'), ('project.model_budget.configure', 'project.model_budget.manage'), ('project.model_call.recovery_child.authorize', 'project.model_call.recovery_child.authorize')) + actions = ( + ("checker.model_call.prepare", "checker.model_call.prepare"), + ("checker.model_call.dispatch", "checker.model_call.dispatch"), + ("checker.model_call.recover", "checker.model_call.recover"), + ("checker.model_call.observe", "checker.model_call.observe"), + ("checker.model_call.complete", "checker.model_call.complete"), + ("checker.model_call.grant", "checker.model_call.grant"), + ("project.model_profile.provision", "project.model_profile.manage"), + ("project.model_budget.configure", "project.model_budget.manage"), + ( + "project.model_call.recovery_child.authorize", + "project.model_call.recovery_child.authorize", + ), + ) if definition.count(anchor) != 2 or any(action in definition for action, _ in actions): raise RuntimeError("model-call audit action constraint shape changed") addition = "".join( @@ -735,8 +805,39 @@ def _upgrade_authorization_vocabulary() -> None: for action, permission in actions ) op.execute("ALTER TABLE public.audit_events DROP CONSTRAINT " + name) - op.execute("ALTER TABLE public.audit_events ADD CONSTRAINT " + name + " " - + definition.replace(anchor, anchor + addition)) + op.execute( + "ALTER TABLE public.audit_events ADD CONSTRAINT " + + name + + " " + + definition.replace(anchor, anchor + addition) + ) + + name = "ck_audit_events_authority_registries" + definition = ( + op.get_bind() + .execute( + text( + "SELECT pg_catalog.pg_get_constraintdef(oid) FROM pg_catalog.pg_constraint " + "WHERE conrelid='public.audit_events'::regclass AND conname=:name" + ), + {"name": name}, + ) + .scalar_one() + ) + anchor = "('task.revision.recover'::character varying)::text" + permissions = tuple(dict.fromkeys(permission for _, permission in actions)) + if definition.count(anchor) != 1 or any( + f"('{permission}'::character varying)::text" in definition for permission in permissions + ): + raise RuntimeError("model-call audit permission registry shape changed") + addition = "".join(f", ('{permission}'::character varying)::text" for permission in permissions) + op.execute("ALTER TABLE public.audit_events DROP CONSTRAINT " + name) + op.execute( + "ALTER TABLE public.audit_events ADD CONSTRAINT " + + name + + " " + + definition.replace(anchor, anchor + addition) + ) def downgrade() -> None: @@ -1152,10 +1253,34 @@ def downgrade() -> None: LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ DECLARE expected_version integer; DECLARE authority public.audit_events%ROWTYPE; +DECLARE registry_digest varchar; +DECLARE registry_phase varchar; BEGIN IF TG_OP<>'INSERT' THEN RAISE EXCEPTION 'checker model profiles are immutable' USING ERRCODE='55000'; END IF; + IF NEW.profile_digest<>public.checker_model_json_digest( + pg_catalog.jsonb_build_object( + 'project_id',NEW.project_id,'profile_version',NEW.profile_version, + 'registry_entry_id',NEW.registry_entry_id, + 'registry_entry_digest',NEW.registry_entry_digest, + 'provider',NEW.provider,'api',NEW.api, + 'model_snapshot',NEW.model_snapshot,'settings',NEW.generation_settings, + 'maximum_request_bytes',NEW.maximum_request_bytes, + 'maximum_output_bytes',NEW.maximum_output_bytes, + 'maximum_response_bytes',NEW.maximum_response_bytes, + 'maximum_input_tokens',NEW.maximum_input_tokens, + 'maximum_output_tokens',NEW.maximum_output_tokens, + 'deadline_ms',NEW.deadline_ms,'call_slots',NEW.call_slots, + 'pricing',pg_catalog.jsonb_build_object( + 'pricing_policy_id',NEW.pricing_policy_id, + 'pricing_policy_version',NEW.pricing_policy_version, + 'pricing_policy_digest',NEW.pricing_policy_digest, + 'uncached_input_nanodollars_per_token',NEW.uncached_input_rate, + 'cached_input_nanodollars_per_token',NEW.cached_input_rate, + 'output_nanodollars_per_token',NEW.output_rate))) THEN + RAISE EXCEPTION 'checker model profile digest is invalid' USING ERRCODE='23514'; + END IF; SELECT * INTO authority FROM public.audit_events WHERE id=NEW.authorization_evidence_id FOR SHARE; IF authority.id IS NULL OR authority.event_domain<>'authority' OR @@ -1165,11 +1290,17 @@ def downgrade() -> None: authority.action_id<>'project.model_profile.provision' OR authority.permission_id<>'project.model_profile.manage' OR authority.denial_code IS NOT NULL OR - authority.after_facts<>pg_catalog.jsonb_build_object( + authority.after_facts::jsonb<>pg_catalog.jsonb_build_object( 'allowed',true,'resource_context_digest', public.checker_model_profile_authority_digest(NEW)) THEN RAISE EXCEPTION 'checker model profile authority is invalid' USING ERRCODE='23514'; END IF; + SELECT entry_digest,phase INTO registry_digest,registry_phase + FROM public.external_checker_registry_entries WHERE id=NEW.registry_entry_id FOR SHARE; + IF registry_digest IS DISTINCT FROM NEW.registry_entry_digest OR + registry_phase IS DISTINCT FROM 'post_submit' THEN + RAISE EXCEPTION 'checker model profile registry binding is invalid' USING ERRCODE='23514'; + END IF; PERFORM pg_advisory_xact_lock(hashtextextended('checker-model-profile:'||NEW.project_id,0)); SELECT coalesce(max(profile_version),0)+1 INTO expected_version FROM public.checker_model_profiles WHERE project_id=NEW.project_id; @@ -1189,6 +1320,14 @@ def downgrade() -> None: IF TG_OP<>'INSERT' THEN RAISE EXCEPTION 'checker model budget configurations are immutable' USING ERRCODE='55000'; END IF; + IF NEW.configuration_digest<>public.checker_model_json_digest( + pg_catalog.jsonb_build_object( + 'project_id',NEW.project_id, + 'configuration_version',NEW.configuration_version, + 'lifetime_limit_nanodollars',NEW.lifetime_limit_nanodollars)) THEN + RAISE EXCEPTION 'checker model budget configuration digest is invalid' + USING ERRCODE='23514'; + END IF; SELECT * INTO authority FROM public.audit_events WHERE id=NEW.authorization_evidence_id FOR SHARE; IF authority.id IS NULL OR authority.event_domain<>'authority' OR @@ -1198,7 +1337,7 @@ def downgrade() -> None: authority.action_id<>'project.model_budget.configure' OR authority.permission_id<>'project.model_budget.manage' OR authority.denial_code IS NOT NULL OR - authority.after_facts<>pg_catalog.jsonb_build_object( + authority.after_facts::jsonb<>pg_catalog.jsonb_build_object( 'allowed',true,'resource_context_digest', public.checker_model_budget_authority_digest(NEW)) THEN RAISE EXCEPTION 'checker model budget authority is invalid' USING ERRCODE='23514'; diff --git a/backend/app/adapters/artifacts/__init__.py b/backend/app/adapters/artifacts/__init__.py index 844ef0aed..b66a6e2c5 100644 --- a/backend/app/adapters/artifacts/__init__.py +++ b/backend/app/adapters/artifacts/__init__.py @@ -189,6 +189,25 @@ def create_artifact_scratch_manager(settings: Settings) -> ArtifactScratchManage ) +def artifact_preparation_service(manager: ArtifactScratchManager) -> ArtifactPreparationService: + """Construct the public composition root's canonical stream preparation owner.""" + return ArtifactPreparationService(manager) + + +def configured_artifact_storage_namespace(settings: Settings, bootstrap): + """Derive the immutable namespace through the existing ART composition root.""" + from app.modules.artifacts.service import artifact_storage_namespace_spec + + return artifact_storage_namespace_spec(settings, bootstrap) + + +async def validate_configured_artifact_storage_namespace(bootstrap, settings, sessions): + """Validate startup namespace custody without exposing the private ART service.""" + from app.modules.artifacts.service import validate_artifact_storage_namespace_at_startup + + return await validate_artifact_storage_namespace_at_startup(bootstrap, settings, sessions) + + def _guide_original_runtime(settings: Settings): """Open the provider, namespace and scratch only when a verified original read begins.""" from app.modules.artifacts.service import artifact_storage_namespace_spec @@ -203,10 +222,13 @@ async def runtime(): try: manager = create_artifact_scratch_manager(settings) namespace = artifact_storage_namespace_spec(settings, bootstrap) - store = bootstrap.initialize_after_namespace_claim(ArtifactStoreNamespaceClaim( - adapter_identity=bootstrap.identity, namespace_identity=bootstrap.namespace_identity, - namespace_fingerprint=namespace.namespace_fingerprint, - )) + store = bootstrap.initialize_after_namespace_claim( + ArtifactStoreNamespaceClaim( + adapter_identity=bootstrap.identity, + namespace_identity=bootstrap.namespace_identity, + namespace_fingerprint=namespace.namespace_fingerprint, + ) + ) yield store, namespace, ArtifactPreparationService(manager) finally: if manager is not None: @@ -224,19 +246,26 @@ def task_guide_documents_port(session: AsyncSession, settings: Settings) -> Task scope = project_guide_document_scope_port(session) return ArtifactTaskGuideDocuments( - session, scope=scope, manifest=SqlAlchemyGuideDocumentManifest(session, scope), + session, + scope=scope, + manifest=SqlAlchemyGuideDocumentManifest(session, scope), runtime=_guide_original_runtime(settings), ) -def project_guide_documents_port(session: AsyncSession, settings: Settings) -> ProjectGuideDocumentContentPort: +def project_guide_documents_port( + session: AsyncSession, settings: Settings +) -> ProjectGuideDocumentContentPort: """Compose a Project Manager's verified original read; the provider opens only on ``open``.""" from app.adapters.projects import project_guide_document_scope_port from app.modules.artifacts.guide_documents import SqlAlchemyGuideDocumentManifest from app.modules.artifacts.task_guide_documents import ArtifactProjectGuideDocuments return ArtifactProjectGuideDocuments( - session, manifest=SqlAlchemyGuideDocumentManifest(session, project_guide_document_scope_port(session)), + session, + manifest=SqlAlchemyGuideDocumentManifest( + session, project_guide_document_scope_port(session) + ), runtime=_guide_original_runtime(settings), ) @@ -316,10 +345,17 @@ async def runtime(): bootstrap.close() from app.adapters.artifacts.internal_workers import continue_guide_setup_after_stored_document - service = GuideArtifactIngestService(runtime, authority, continue_guide_setup_after_stored_document) + + service = GuideArtifactIngestService( + runtime, authority, continue_guide_setup_after_stored_document + ) return PreparedGuideArtifactIngestCommand( - service, authority, targets, - maximum_document_bytes=min(settings.artifact_maximum_bytes, settings.project_agent_max_document_bytes), + service, + authority, + targets, + maximum_document_bytes=min( + settings.artifact_maximum_bytes, settings.project_agent_max_document_bytes + ), maximum_total_bytes=settings.project_agent_max_total_document_bytes, ) @@ -389,6 +425,7 @@ def get_submission_bundle_preparation_command( PreparedSubmissionBundlePreparationCommand, SubmissionBundlePreparationRuntime, ) + settings = request.app.state.settings request_id, correlation_id = (UUID(value) for value in request_ids(request)) task_contexts = task_submission_context_port(session) @@ -510,6 +547,7 @@ def guide_document_manifest_port(session: AsyncSession): """Compose ART's metadata-only current document manifest capability.""" from app.modules.artifacts.guide_documents import SqlAlchemyGuideDocumentManifest from app.adapters.projects import project_guide_document_scope_port + return SqlAlchemyGuideDocumentManifest(session, project_guide_document_scope_port(session)) @@ -532,15 +570,25 @@ async def guide_document_access_runtime(session_factory, attempt_id, manifest, c manager = create_artifact_scratch_manager(get_settings()) try: with _artifact_internal_runtime() as (store, namespace): + def authority(session): request_id = uuid4() return PreparedGuideSourceReadAuthorization( - session, request_id=request_id, correlation_id=request_id, + session, + request_id=request_id, + correlation_id=request_id, ) + grant = ScopedGuideDocumentGrant( - session_factory, store, namespace, ArtifactPreparationService(manager), authority, - scope_factory=project_guide_document_scope_port, manifest_factory=guide_document_manifest_port, - attempt_id=attempt_id, manifest=manifest, + session_factory, + store, + namespace, + ArtifactPreparationService(manager), + authority, + scope_factory=project_guide_document_scope_port, + manifest_factory=guide_document_manifest_port, + attempt_id=attempt_id, + manifest=manifest, lifetime_seconds=configuration.timeout_seconds, maximum_document_bytes=configuration.maximum_document_bytes, ) @@ -552,15 +600,22 @@ def authority(session): manager.close() -def post_submission_materialization(*, sessions, store, namespace, preparation, inspector) -> PostSubmissionMaterializationPort: +def post_submission_materialization( + *, sessions, store, namespace, preparation, inspector +) -> PostSubmissionMaterializationPort: """Compose exact input with fresh materializer authority and CHECKERS lease custody.""" from app.adapters.tasks import submitted_bundle_port from app.modules.artifacts.post_submit_materialization import PostSubmissionMaterializer from app.adapters.auth import post_submit_materialization_authority from app.adapters.checkers import current_post_submit_execution + return PostSubmissionMaterializer( - sessions=sessions, tasks=submitted_bundle_port, store=store, namespace=namespace, - preparation=preparation, inspector=inspector, + sessions=sessions, + tasks=submitted_bundle_port, + store=store, + namespace=namespace, + preparation=preparation, + inspector=inspector, authority=post_submit_materialization_authority, current_execution=current_post_submit_execution, ) @@ -573,7 +628,9 @@ def __init__(self, sessions) -> None: self._sessions = sessions async def materialize( - self, facts: ExecuteFacts, consumer: PostSubmissionMaterialConsumer, + self, + facts: ExecuteFacts, + consumer: PostSubmissionMaterialConsumer, ) -> PostSubmissionMaterializationResult: from app.core.config import get_settings from app.adapters.artifacts.internal_workers import ( @@ -634,15 +691,26 @@ def checker_output_storage( def authority(session, identity): request_id = uuid4() return PreparedArtifactInternalAuthority( - session, service_identity=identity, request_id=request_id, correlation_id=request_id, + session, + service_identity=identity, + request_id=request_id, + correlation_id=request_id, ) return CheckerArtifactOutputService( - sessions=sessions, store=store, namespace=namespace, preparation=preparation, settings=settings, + sessions=sessions, + store=store, + namespace=namespace, + preparation=preparation, + settings=settings, reservations=checker_output_reservations, authority=checker_output_write_authorization, - internal_authority=lambda session: authority(session, ServiceIdentity.ARTIFACT_PUT_RESOLVER), - verification_authority=lambda session: authority(session, ServiceIdentity.ARTIFACT_VERIFIER), + internal_authority=lambda session: authority( + session, ServiceIdentity.ARTIFACT_PUT_RESOLVER + ), + verification_authority=lambda session: authority( + session, ServiceIdentity.ARTIFACT_VERIFIER + ), ) @@ -650,8 +718,13 @@ def checker_output_binding(session, *, namespace) -> CheckerOutputBindingPort: """Bind one exact private call effect to independently verified ART bytes.""" from app.adapters.auth import checker_output_binding_authorization from app.modules.artifacts.checker_output_bindings import CheckerOutputBindingService - return CheckerOutputBindingService(session, namespace_fingerprint=namespace.namespace_fingerprint, - reservations=checker_output_reservations(session), authority=checker_output_binding_authorization(session)) + + return CheckerOutputBindingService( + session, + namespace_fingerprint=namespace.namespace_fingerprint, + reservations=checker_output_reservations(session), + authority=checker_output_binding_authorization(session), + ) def checker_model_evidence(session): @@ -668,16 +741,23 @@ async def get_task_import_source_commands( ) -> TaskImportSourceCommandPort: """Capture actor facts, then compose the same source owner for public commands.""" await session.rollback() - return task_import_source_commands(session, settings=request.app.state.settings, context=context) + return task_import_source_commands( + session, settings=request.app.state.settings, context=context + ) def task_import_source_commands(session: AsyncSession, *, settings, context): """Compose source reads/bindings and lazy provider runtime through one owner path.""" from app.adapters.auth import task_import_source_authorization from app.modules.artifacts.service import ( - ArtifactAdmissionService, ArtifactStorageOrchestrator, artifact_storage_namespace_spec, + ArtifactAdmissionService, + ArtifactStorageOrchestrator, + artifact_storage_namespace_spec, + ) + from app.modules.artifacts.task_import_sources import ( + ArtifactTaskImportSourceCommands, + TaskImportSourceRuntime, ) - from app.modules.artifacts.task_import_sources import ArtifactTaskImportSourceCommands, TaskImportSourceRuntime request_id, correlation_id = context.request_id, context.correlation_id @@ -692,19 +772,34 @@ async def runtime(): try: manager = create_artifact_scratch_manager(settings) namespace = artifact_storage_namespace_spec(settings, bootstrap) - store = bootstrap.initialize_after_namespace_claim(ArtifactStoreNamespaceClaim( - adapter_identity=bootstrap.identity, namespace_identity=bootstrap.namespace_identity, - namespace_fingerprint=namespace.namespace_fingerprint, - )) - for identity in (ServiceIdentity.ARTIFACT_PUT_RESOLVER, ServiceIdentity.ARTIFACT_VERIFIER): - authorities.append(PreparedArtifactInternalAuthority( - session, service_identity=identity, request_id=request_id, correlation_id=correlation_id, - )) + store = bootstrap.initialize_after_namespace_claim( + ArtifactStoreNamespaceClaim( + adapter_identity=bootstrap.identity, + namespace_identity=bootstrap.namespace_identity, + namespace_fingerprint=namespace.namespace_fingerprint, + ) + ) + for identity in ( + ServiceIdentity.ARTIFACT_PUT_RESOLVER, + ServiceIdentity.ARTIFACT_VERIFIER, + ): + authorities.append( + PreparedArtifactInternalAuthority( + session, + service_identity=identity, + request_id=request_id, + correlation_id=correlation_id, + ) + ) yield TaskImportSourceRuntime( - store, namespace, ArtifactPreparationService(manager), ArtifactAdmissionService(session, settings, namespace), + store, + namespace, + ArtifactPreparationService(manager), + ArtifactAdmissionService(session, settings, namespace), ArtifactStorageOrchestrator(session, store, namespace, settings, authorities[0]), ArtifactStorageOrchestrator(session, store, namespace, settings, authorities[1]), - authorities[0].denial_boundary, authorities[1].denial_boundary, + authorities[0].denial_boundary, + authorities[1].denial_boundary, ) finally: for authority in authorities: @@ -714,9 +809,11 @@ async def runtime(): bootstrap.close() return ArtifactTaskImportSourceCommands( - session, actor_profile_id=context.actor_profile_id, + session, + actor_profile_id=context.actor_profile_id, identity_link_id=context.identity_link_id, - authorization=task_import_source_authorization(session, context), runtime=runtime, + authorization=task_import_source_authorization(session, context), + runtime=runtime, ) diff --git a/backend/app/adapters/checkers/__init__.py b/backend/app/adapters/checkers/__init__.py index d0e52b706..dc2242884 100644 --- a/backend/app/adapters/checkers/__init__.py +++ b/backend/app/adapters/checkers/__init__.py @@ -17,6 +17,10 @@ ExternalServiceAdapterIdentity, ExternalServiceUnavailableError, ) +from app.interfaces.checker_model_provider import ( + CHECKER_MODEL_CAPABILITY_KEY, + CheckerModelProvider, +) from app.modules.tasks.api import TaskSubmissionContextFacts from app.modules.checkers.api.external_policy import LockedExternalPostSubmitPolicy @@ -64,6 +68,30 @@ def external_checker_execution_factory( return factory +def create_checker_model_provider(settings): + """Construct the sole provider registered for one locked model profile.""" + from openai import AsyncOpenAI + + from app.adapters.checkers.model_provider import ( + OPENAI_RESPONSES_PROVIDER_KEY, + OpenAIResponsesCheckerModelProvider, + ) + + factory = ExternalServiceAdapterFactory[CheckerModelProvider](CHECKER_MODEL_CAPABILITY_KEY) + factory.register( + OPENAI_RESPONSES_PROVIDER_KEY, + lambda: OpenAIResponsesCheckerModelProvider( + settings, + AsyncOpenAI( + api_key=settings.api_key, + max_retries=0, + timeout=settings.request_timeout_seconds, + ), + ), + ) + return factory.create(OPENAI_RESPONSES_PROVIDER_KEY) + + def configured_external_checker_execution_factory(settings): """Map configured image-execution settings into the sole adapter factory.""" if ( @@ -123,12 +151,15 @@ def configured_external_checker_execution(settings) -> ExternalCheckerExecutionA return _ConfiguredExternalCheckerExecution(settings) -def configured_post_submit_external_execution(settings, *, sessions) -> ExternalCheckerExecutionAdapter: +def configured_post_submit_external_execution( + settings, *, sessions +) -> ExternalCheckerExecutionAdapter: """Add durable project model custody to the existing Rust image transport.""" from app.adapters.checkers.model_execution import ModelAwareExternalExecution return ModelAwareExternalExecution( - execution=configured_external_checker_execution(settings), sessions=sessions, + execution=configured_external_checker_execution(settings), + sessions=sessions, ) @@ -439,6 +470,13 @@ def model_invocation_grants(session, authority): ) +def checker_model_proxy(**ports): + """Compose provider orchestration from typed CHECKERS, AUTH and ART ports.""" + from app.adapters.checkers.model_proxy import CheckerModelProxy + + return CheckerModelProxy(**ports) + + def current_post_submit_execution(session): """Keep ART currentness checks behind the CHECKERS owner port.""" from app.modules.checkers.execution_coordination import CurrentExecution @@ -591,7 +629,9 @@ def executor(child, original, payload, request): original=original, payload=payload, request=request, - selected_execution=configured_post_submit_external_execution(get_settings(), sessions=sessions), + selected_execution=configured_post_submit_external_execution( + get_settings(), sessions=sessions + ), ) return PostSubmitDeliveryReconciliationHandler( diff --git a/backend/app/adapters/checkers/model_provider.py b/backend/app/adapters/checkers/model_provider.py index eb4222455..ed0e450d6 100644 --- a/backend/app/adapters/checkers/model_provider.py +++ b/backend/app/adapters/checkers/model_provider.py @@ -17,7 +17,6 @@ CheckerModelCreateOutcome, CheckerModelCreateResult, CheckerModelObservation, - CheckerModelProvider, CheckerModelProviderStatus, CheckerModelRecoveryOutcome, CheckerModelRecoveryResult, @@ -25,10 +24,7 @@ CheckerModelResponseReference, CheckerModelUsage, ) -from app.interfaces.external_services import ( - ExternalServiceAdapterFactory, - ExternalServiceAdapterIdentity, -) +from app.interfaces.external_services import ExternalServiceAdapterIdentity OPENAI_RESPONSES_PROVIDER_KEY = "openai_responses" @@ -59,6 +55,7 @@ class OpenAIResponsesSettings: text_verbosity: TextVerbosity def __post_init__(self) -> None: + """Reject credentials, model identity, or limits outside the closed profile bounds.""" if ( not isinstance(self.api_key, str) or not self.api_key @@ -85,11 +82,15 @@ def __post_init__(self) -> None: class _InputTokenResource(Protocol): + """Describe the provider endpoint that counts input without creating an effect.""" + async def count(self, **kwargs: Any) -> object: """Count the exact request input without creating a response.""" class _ResponsesResource(Protocol): + """Describe the exact stored-response operations used by this adapter.""" + input_tokens: _InputTokenResource async def create(self, **kwargs: Any) -> object: @@ -100,6 +101,8 @@ async def retrieve(self, response_id: str, **kwargs: Any) -> object: class _OpenAIClient(Protocol): + """Describe the bounded client surface owned by one profile adapter.""" + responses: _ResponsesResource async def close(self) -> None: @@ -112,6 +115,7 @@ class OpenAIResponsesCheckerModelProvider: __slots__ = ("_client", "_settings") def __init__(self, settings: OpenAIResponsesSettings, client: _OpenAIClient) -> None: + """Bind validated trusted settings to one structurally valid SDK client.""" if type(settings) is not OpenAIResponsesSettings: raise ValueError("OpenAI checker model settings are invalid") if not hasattr(client, "responses") or not callable(getattr(client, "close", None)): @@ -216,6 +220,7 @@ async def aclose(self) -> None: await self._client.close() def _request_parameters(self, prompt: str) -> dict[str, object]: + """Build provider parameters solely from the locked profile and supplied prompt.""" return { "model": self._settings.model, "input": prompt, @@ -228,6 +233,7 @@ def _request_parameters(self, prompt: str) -> dict[str, object]: } def _observation(self, response: object) -> CheckerModelObservation: + """Validate provider identity, bounds, status, output, and usage as one observation.""" reference = CheckerModelResponseReference(getattr(response, "id", None)) if getattr(response, "service_tier", None) != "default": raise ValueError("provider pricing tier differs from the locked standard rates") @@ -260,28 +266,8 @@ def _observation(self, response: object) -> CheckerModelObservation: ) -def create_checker_model_provider( - settings: OpenAIResponsesSettings, -) -> CheckerModelProvider: - """Construct the sole explicitly registered provider for one locked profile.""" - from openai import AsyncOpenAI - - factory = ExternalServiceAdapterFactory[CheckerModelProvider](CHECKER_MODEL_CAPABILITY_KEY) - factory.register( - OPENAI_RESPONSES_PROVIDER_KEY, - lambda: OpenAIResponsesCheckerModelProvider( - settings, - AsyncOpenAI( - api_key=settings.api_key, - max_retries=0, - timeout=settings.request_timeout_seconds, - ), - ), - ) - return factory.create(OPENAI_RESPONSES_PROVIDER_KEY) - - def _known_response(response: object) -> CheckerModelResponseReference | None: + """Recover a valid provider response identity without trusting other response fields.""" try: return CheckerModelResponseReference(getattr(response, "id", None)) except (TypeError, ValueError): @@ -289,6 +275,7 @@ def _known_response(response: object) -> CheckerModelResponseReference | None: def _usage(value: object) -> CheckerModelUsage: + """Normalize the provider's nested token counters through the strict usage contract.""" input_details = getattr(value, "input_tokens_details", None) output_details = getattr(value, "output_tokens_details", None) return CheckerModelUsage( diff --git a/backend/app/modules/checkers/model_calls/proxy.py b/backend/app/adapters/checkers/model_proxy.py similarity index 97% rename from backend/app/modules/checkers/model_calls/proxy.py rename to backend/app/adapters/checkers/model_proxy.py index b73669fb3..619ab3a7a 100644 --- a/backend/app/modules/checkers/model_calls/proxy.py +++ b/backend/app/adapters/checkers/model_proxy.py @@ -84,6 +84,11 @@ async def handle(self, request: ModelProxyRequest) -> ModelProxyReply: return _unavailable("model_call_unavailable") async def _handle(self, request: ModelProxyRequest) -> ModelProxyReply: + # Reject malformed bytes before consuming invocation custody or reserving spend. + try: + provider_request = CheckerModelRequest(request.prompt) + except ValueError: + return _unavailable("model_request_invalid") data = _canonical_bytes( { "schema_version": "workstream.checker_model_request.v1", @@ -150,7 +155,7 @@ async def _handle(self, request: ModelProxyRequest) -> ModelProxyReply: if dispatch is not None and not dispatch.replayed: try: created = await asyncio.wait_for( - provider.create(CheckerModelRequest(request.prompt)), + provider.create(provider_request), timeout=max(0.001, deadline - asyncio.get_running_loop().time()), ) except TimeoutError: diff --git a/backend/app/adapters/checkers/model_runtime.py b/backend/app/adapters/checkers/model_runtime.py index 594e715a8..81cb2e8bf 100644 --- a/backend/app/adapters/checkers/model_runtime.py +++ b/backend/app/adapters/checkers/model_runtime.py @@ -4,26 +4,28 @@ import os from app.adapters.artifacts import ( + artifact_preparation_service, artifact_provider_activation_guard, checker_output_binding, checker_output_storage, + configured_artifact_storage_namespace, create_artifact_scratch_manager, create_artifact_store_bootstrap, + validate_configured_artifact_storage_namespace, ) from app.adapters.auth import checker_model_service_authorization -from app.adapters.checkers import model_call_service, model_invocation_grants, model_profile_reader +from app.adapters.checkers import ( + checker_model_proxy, + create_checker_model_provider, + model_call_service, + model_invocation_grants, + model_profile_reader, +) from app.adapters.checkers.model_provider import ( OpenAIResponsesSettings, - create_checker_model_provider, ) from app.adapters.checkers.model_proxy_transport import ModelProxyTransport -from app.modules.artifacts.preparation import ArtifactPreparationService -from app.modules.artifacts.service import ( - artifact_storage_namespace_spec, - validate_artifact_storage_namespace_at_startup, -) from app.modules.checkers.api.model_calls import ModelCallUnavailable, ProjectModelProfile -from app.modules.checkers.model_calls.proxy import CheckerModelProxy @asynccontextmanager @@ -66,22 +68,22 @@ async def configured_model_proxy_runtime(settings, sessions): ) manager = None try: - claim = await validate_artifact_storage_namespace_at_startup( + claim = await validate_configured_artifact_storage_namespace( bootstrap, settings, sessions, ) store = bootstrap.initialize_after_namespace_claim(claim) - namespace = artifact_storage_namespace_spec(settings, bootstrap) + namespace = configured_artifact_storage_namespace(settings, bootstrap) manager = create_artifact_scratch_manager(settings) outputs = checker_output_storage( sessions=sessions, store=store, namespace=namespace, - preparation=ArtifactPreparationService(manager), + preparation=artifact_preparation_service(manager), settings=settings, ) - proxy = CheckerModelProxy( + proxy = checker_model_proxy( sessions=sessions, authorization=checker_model_service_authorization, grants=model_invocation_grants, diff --git a/backend/app/api/deps/checker_models.py b/backend/app/api/deps/checker_models.py index 3a8ae0133..d98a870f5 100644 --- a/backend/app/api/deps/checker_models.py +++ b/backend/app/api/deps/checker_models.py @@ -26,6 +26,7 @@ from app.modules.checkers.api.model_calls import ( ModelBudgetProvisioningFacts, ModelCallBudgetUnavailable, + ModelCallAdministrationPort, ModelCallConflict, ModelCallUnavailable, ModelProfileProvisioningFacts, @@ -36,7 +37,6 @@ ProjectModelProfile, ProjectModelProfileSpec, ) -from app.modules.checkers.model_calls.service import ModelCallService from app.schemas.auth import AuthVerificationResult @@ -79,11 +79,19 @@ async def require_model_admin_human( raise _error(404) +async def require_model_recovery_human( + result: Annotated[AuthVerificationResult, Depends(get_auth_verification_result)], +) -> None: + """Conceal recovery controls from every nonhuman public caller.""" + if result.token.subject_kind != "human": + raise _error(404) + + @dataclass(frozen=True) class CheckerModelAdminCommands: """Bind path, actor and caller key to the existing atomic CHECKERS owner.""" - service: ModelCallService + service: ModelCallAdministrationPort actor_profile_id: UUID operation_id: UUID @@ -121,10 +129,9 @@ async def prepare_recovery_child( parent_attempt_id: UUID, request: ModelRecoveryChildRequest, ) -> PreparedModelCallAttempt: - if request.execution.project_id != project_id or request.slot.project_id != project_id: + if request.slot.project_id != project_id: raise ModelCallConflict("model_recovery_project_conflict") return await self.service.prepare_recovery_child( - request.execution, request.slot, registry_entry_id=request.registry_entry_id, parent_attempt_id=parent_attempt_id, @@ -139,9 +146,11 @@ async def _model_admin_operation( operation_id: UUID, resolved: ResolvedActor, session: AsyncSession, + *, + correlation_id: UUID | None = None, ) -> AsyncIterator[CheckerModelAdminCommands]: request_id, _ = (UUID(value) for value in request_ids(request)) - context = _authorization_context(resolved, request_id, operation_id) + context = _authorization_context(resolved, request_id, correlation_id or operation_id) await session.rollback() try: async with session.begin(): @@ -155,7 +164,7 @@ async def _model_admin_operation( except ModelCallUnavailable as exc: # AUTH has already staged its canonical denial. Preserve that evidence # before the public boundary conceals the private authority reason. - if str(exc) == "model_call_authority_unavailable": + if str(exc) == "model_call_authority_unavailable" and session.is_active: await session.commit() raise except ModelCallBudgetUnavailable as exc: @@ -183,10 +192,29 @@ async def get_checker_model_admin_commands( yield commands +async def get_checker_model_recovery_commands( + request: Request, + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> AsyncIterator[CheckerModelAdminCommands]: + """Compose recovery under fresh request IDs; the parent/candidate own replay.""" + request_id, correlation_id = (UUID(value) for value in request_ids(request)) + async with _model_admin_operation( + request, + request_id, + resolved, + session, + correlation_id=correlation_id, + ) as commands: + yield commands + + __all__ = ( "MODEL_ADMIN_IDEMPOTENCY_PARAMETER", "CheckerModelAdminCommands", "get_checker_model_admin_commands", + "get_checker_model_recovery_commands", "require_model_admin_human", "require_model_admin_key", + "require_model_recovery_human", ) diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 260e056ec..ea973671e 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -125,7 +125,11 @@ from app.modules.tasks.revision.models import TaskRevisionPreparation, TaskRevisionExpiration, TaskRevisionExhaustion # noqa: F401 from app.modules.checkers.model_calls.models import ( # noqa: F401 - CheckerModelProfile, CheckerModelBudgetAccount, CheckerModelBudgetConfiguration, - CheckerModelCallSlot, CheckerModelCallAttempt, CheckerModelCallRecovery, + CheckerModelProfile, + CheckerModelBudgetAccount, + CheckerModelBudgetConfiguration, + CheckerModelCallSlot, + CheckerModelCallAttempt, + CheckerModelCallRecovery, + CheckerModelInvocationGrant, ) -from app.modules.checkers.model_calls.grants import CheckerModelInvocationGrant # noqa: F401 diff --git a/backend/app/modules/artifacts/api/checker_output_authority.py b/backend/app/modules/artifacts/api/checker_output_authority.py new file mode 100644 index 000000000..479908861 --- /dev/null +++ b/backend/app/modules/artifacts/api/checker_output_authority.py @@ -0,0 +1,76 @@ +"""Public ART authority facts for call-stable checker output custody.""" + +from dataclasses import dataclass +from typing import Protocol, final +from uuid import UUID + +from app.modules.checkers.api.output_custody import ( + CheckerOutputReservation, + CheckerOutputSelector, +) + + +@final +@dataclass(frozen=True, slots=True) +class CheckerOutputCommitmentAuthorityFacts: + """Server-computed commitment without source bytes or scratch identity.""" + + reservation: CheckerOutputReservation + slot_key: str + sha256: str + byte_count: int + media_type: str + + +@final +@dataclass(frozen=True, slots=True) +class CheckerOutputStoredFacts: + """Exact storage facts selected by ART under the prepared call selector.""" + + put_attempt_id: UUID + checker_request_digest: str + sha256: str + byte_count: int + media_type: str + status: str + content_id: UUID | None + replica_id: UUID | None + verification_receipt_id: UUID | None + + +class CheckerOutputWriteAuthority(Protocol): + """Fresh write authority; no prepared handle survives storage I/O.""" + + async def preflight(self, selector: CheckerOutputSelector) -> None: ... + + async def prepare(self, selector: CheckerOutputSelector) -> None: ... + + async def consume(self, facts: CheckerOutputCommitmentAuthorityFacts) -> None: ... + + async def consume_existing( + self, reservation: CheckerOutputReservation, facts: CheckerOutputStoredFacts + ) -> None: ... + + def discard(self) -> None: ... + + +class CheckerOutputBindingAuthority(Protocol): + """Fresh publication authority for independently verified call evidence.""" + + async def preflight(self, selector: CheckerOutputSelector) -> None: ... + + async def prepare(self, selector: CheckerOutputSelector) -> None: ... + + async def consume( + self, reservation: CheckerOutputReservation, facts: CheckerOutputStoredFacts + ) -> None: ... + + def discard(self) -> None: ... + + +__all__ = ( + "CheckerOutputBindingAuthority", + "CheckerOutputCommitmentAuthorityFacts", + "CheckerOutputStoredFacts", + "CheckerOutputWriteAuthority", +) diff --git a/backend/app/modules/artifacts/checker_output_bindings.py b/backend/app/modules/artifacts/checker_output_bindings.py index 35b08e5fc..4c0c26be9 100644 --- a/backend/app/modules/artifacts/checker_output_bindings.py +++ b/backend/app/modules/artifacts/checker_output_bindings.py @@ -1,46 +1,26 @@ """Verified checker-output bindings in the caller's final-result transaction.""" -from typing import Protocol from uuid import UUID from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession from app.core.identifiers import new_record_id +from app.modules.artifacts.api.checker_output_authority import ( + CheckerOutputBindingAuthority, +) from app.modules.checkers.api.output_custody import ( CheckerOutputBindingRequest, CheckerOutputBindingResult, - CheckerOutputReservation, CheckerOutputReservationPort, CheckerOutputSelector, CheckerOutputUnavailable, ) from app.modules.actors.api import ServiceIdentity -from app.modules.artifacts.checker_output_custody import ( - CheckerOutputStoredFacts, - select_checker_output, -) +from app.modules.artifacts.checker_output_custody import select_checker_output from app.modules.artifacts.models import ArtifactBinding -class CheckerOutputBindingAuthority(Protocol): - """Prepare authority before CHECKERS locks, consume exact ART custody afterward.""" - - async def preflight(self, selector: CheckerOutputSelector) -> None: - """Deny before any owner or artifact lookup.""" - - async def prepare(self, selector: CheckerOutputSelector) -> None: - """Stabilize exact service authority before feature row locks.""" - - async def consume( - self, reservation: CheckerOutputReservation, facts: CheckerOutputStoredFacts - ) -> None: - """Consume binding action authority in this caller-owned transaction.""" - - def discard(self) -> None: - """Release process-local authority without committing or rolling back.""" - - class CheckerOutputBindingService: """Flush one immutable binding; never commit the caller's result transaction.""" diff --git a/backend/app/modules/artifacts/checker_output_custody.py b/backend/app/modules/artifacts/checker_output_custody.py index 7e2a00e3c..ee27ed601 100644 --- a/backend/app/modules/artifacts/checker_output_custody.py +++ b/backend/app/modules/artifacts/checker_output_custody.py @@ -1,12 +1,12 @@ """Exact ART-owned output selection shared by storage recovery and binding.""" -from dataclasses import dataclass from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.hashing import canonical_json_hash +from app.modules.artifacts.api.checker_output_authority import CheckerOutputStoredFacts from app.modules.artifacts.models import ( ArtifactContent, ArtifactPutAttempt, @@ -26,22 +26,6 @@ ) -@dataclass(frozen=True, slots=True) -class CheckerOutputStoredFacts: - """Exact storage facts consumed by fresh output action authority.""" - - put_attempt_id: UUID - checker_request_digest: str - sha256: str - byte_count: int - media_type: str - status: str - content_id: UUID | None - replica_id: UUID | None - verification_receipt_id: UUID | None - - - async def select_checker_output( session: AsyncSession, *, diff --git a/backend/app/modules/artifacts/checker_outputs.py b/backend/app/modules/artifacts/checker_outputs.py index 69ab6895e..a23cb0c20 100644 --- a/backend/app/modules/artifacts/checker_outputs.py +++ b/backend/app/modules/artifacts/checker_outputs.py @@ -1,7 +1,6 @@ """Bounded checker bytes through the existing durable ART storage workflow.""" from collections.abc import Callable -from typing import Protocol from uuid import UUID import hashlib @@ -9,19 +8,18 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.core.config import Settings +from app.modules.artifacts.api.checker_output_authority import ( + CheckerOutputWriteAuthority, +) from app.modules.checkers.api.output_custody import ( CheckerOutputArtifactRequest, CheckerOutputArtifactResult, - CheckerOutputReservation, CheckerOutputReservationPort, CheckerOutputSelector, CheckerOutputUnavailable, ) from app.interfaces.artifacts import ArtifactStore, ArtifactByteRange, ArtifactIntegrityError -from app.modules.artifacts.checker_output_custody import ( - CheckerOutputStoredFacts, - select_checker_output, -) +from app.modules.artifacts.checker_output_custody import select_checker_output from app.modules.artifacts.models import ArtifactVerificationJob, ArtifactReplica from app.modules.checkers.api.model_calls import ModelCallEvidence from app.modules.artifacts.preparation import ArtifactPreparationService @@ -36,27 +34,6 @@ ) -class CheckerOutputWriteAuthority(Protocol): - """Fresh write authority; no prepared handle survives a storage operation.""" - - async def preflight(self, selector: CheckerOutputSelector) -> None: - """Deny before owner facts, source iteration or scratch access.""" - - async def prepare(self, selector: CheckerOutputSelector) -> None: - """Stabilize service authority before CHECKERS and ART transaction locks.""" - - async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: - """Bind the exact active worker, slot and byte commitment before admission.""" - - async def consume_existing( - self, reservation: CheckerOutputReservation, facts: CheckerOutputStoredFacts - ) -> None: - """Authorize exact stored output recovery with the current worker lease.""" - - def discard(self) -> None: - """Dispose transaction-local authority after each phase.""" - - class CheckerArtifactOutputService: """Own preparation lifetime; reuse put/observation and independent verification.""" diff --git a/backend/app/modules/artifacts/schemas.py b/backend/app/modules/artifacts/schemas.py index ff1241061..f111a993d 100644 --- a/backend/app/modules/artifacts/schemas.py +++ b/backend/app/modules/artifacts/schemas.py @@ -84,12 +84,6 @@ def checker_output_request_digest_facts( } -class CheckerOutputAdmissionAuthority(Protocol): - """Consume caller-prepared authority for one exact output reservation.""" - - async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: ... - - @final @dataclass(frozen=True, slots=True) class SubmissionBundleArtifactAdmissionRequest: diff --git a/backend/app/modules/artifacts/service.py b/backend/app/modules/artifacts/service.py index f13844477..40cad780d 100644 --- a/backend/app/modules/artifacts/service.py +++ b/backend/app/modules/artifacts/service.py @@ -77,6 +77,10 @@ ArtifactRecoveryAuthority, ArtifactRecoveryAuthorityFacts, ) +from app.modules.artifacts.api.checker_output_authority import ( + CheckerOutputCommitmentAuthorityFacts, + CheckerOutputWriteAuthority, +) from app.modules.artifacts.provider_activation import require_provider_activation_for_terminal from app.modules.artifacts.authorization import ( GuideArtifactPreparedAuthorization, @@ -100,7 +104,6 @@ ArtifactRecoveryIneligibleError, ArtifactRecoveryNotFoundError, DenyArtifactInternalAuthority, - CheckerOutputAdmissionAuthority, CheckerOutputArtifactAdmissionRequest, GuideArtifactAdmissionRequest, GuideArtifactIngestAuthorityFacts, @@ -115,7 +118,6 @@ from app.modules.artifacts.sources import CommittedArtifactSource, PreparedArtifact from app.modules.checkers.api.output_custody import ( CheckerOutputReservation, - CheckerOutputSelector, CheckerOutputUnavailable, ) from app.modules.authorization.runtime import ( @@ -2240,7 +2242,7 @@ async def admit( submission_prepared_authorization: SubmissionBundlePreparedAuthorization | None = None, submission_task_contexts: TaskSubmissionContextPort | None = None, submission_project_contexts: ProjectLockedPolicyContextPort | None = None, - checker_output_authority: CheckerOutputAdmissionAuthority | None = None, + checker_output_authority: CheckerOutputWriteAuthority | None = None, task_import_source_authority: TaskImportSourceAdmissionAuthority | None = None, prepared_authorization: PreparedAuthorizationHandle | None = None, existing_transaction: bool = False, @@ -2261,7 +2263,15 @@ async def admit( ): if type(request) is CheckerOutputArtifactAdmissionRequest: assert checker_output_authority is not None - await checker_output_authority.consume(request) + await checker_output_authority.consume( + CheckerOutputCommitmentAuthorityFacts( + reservation=request.reservation, + slot_key=request.slot_key, + sha256=commitment.sha256, + byte_count=commitment.byte_count, + media_type=commitment.media_type, + ) + ) import_facts = None if type(request) is TaskImportSourceAdmissionRequest: if task_import_source_authority is None: diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index d0467b301..2f143e7e7 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -499,11 +499,7 @@ def _active( _active(ActionId.ACTOR_PROFILE_DEACTIVATE, PermissionId.ACTOR_PROFILE_DEACTIVATE, ActionOwner.AUTH_09D_A), _active(ActionId.ACTOR_IDENTITY_LINK_READ, PermissionId.ACTOR_IDENTITY_LINK_READ, ActionOwner.AUTH_09C), _active(ActionId.ACTOR_IDENTITY_LINK_REVOKE, PermissionId.ACTOR_IDENTITY_LINK_REVOKE, ActionOwner.AUTH_09D_B), - _active( - ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, - PermissionId.ACTOR_IDENTITY_LINK_REACTIVATE, - ActionOwner.AUTH_09D_B, - ), + _active(ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, PermissionId.ACTOR_IDENTITY_LINK_REACTIVATE, ActionOwner.AUTH_09D_B), _active(ActionId.ACTOR_SERVICE_PROVISION, PermissionId.ACTOR_SERVICE_PROVISION, ActionOwner.AUTH_09B), _active(ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, PermissionId.PROJECT_ROLE_GRANT_MANAGE, ActionOwner.AUTH_10B), _active(ActionId.PROJECT_ROLE_GRANT_LIST, PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B), @@ -513,114 +509,34 @@ def _active( _active(ActionId.PROJECT_READ, PermissionId.PROJECT_READ, ActionOwner.AUTH_11B), _active(ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.AUTH_11B), _active(ActionId.PROJECT_SETUP_RUN_READ, PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, ActionOwner.AUTH_11C1), - _active( - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, - PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, - ActionOwner.AUTH_11C1, - ), - _active( - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, - PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, - ActionOwner.AUTH_11C1, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, - PermissionId.PROJECT_EFFECTIVE_POLICY_READ, - ActionOwner.AUTH_11C1, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, - PermissionId.PROJECT_EFFECTIVE_POLICY_READ, - ActionOwner.AUTH_11C1, - ), - _active( - ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, - PermissionId.PROJECT_EFFECTIVE_POLICY_READ, - ActionOwner.AUTH_11C2, - ), - _active( - ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, - PermissionId.PROJECT_EFFECTIVE_POLICY_READ, - ActionOwner.AUTH_11C2, - ), + _active(ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, ActionOwner.AUTH_11C1), + _active(ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, ActionOwner.AUTH_11C1), + _active(ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, PermissionId.PROJECT_EFFECTIVE_POLICY_READ, ActionOwner.AUTH_11C1), + _active(ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, PermissionId.PROJECT_EFFECTIVE_POLICY_READ, ActionOwner.AUTH_11C1), + _active(ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, PermissionId.PROJECT_EFFECTIVE_POLICY_READ, ActionOwner.AUTH_11C2), + _active(ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, PermissionId.PROJECT_EFFECTIVE_POLICY_READ, ActionOwner.AUTH_11C2), _active(ActionId.PROJECT_ACTIVE_GUIDE_READ, PermissionId.PROJECT_READ, ActionOwner.AUTH_11C2), _active(ActionId.PROJECT_CREATE, PermissionId.PROJECT_CREATE, ActionOwner.AUTH_12C), _active(ActionId.PROJECT_GUIDE_CREATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D), _active(ActionId.PROJECT_GUIDE_UPDATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D), _active(ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, - PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, - ActionOwner.AUTH_12I, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, - PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, - ActionOwner.AUTH_12I, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, - PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, - ActionOwner.AUTH_12I, - ), + _active(ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, ActionOwner.AUTH_12I), + _active(ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionOwner.AUTH_12I), + _active(ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionOwner.AUTH_12I), _active(ActionId.PROJECT_REVIEW_POLICY_UPDATE, PermissionId.PROJECT_REVIEW_POLICY_MANAGE, ActionOwner.XINT_003_02B), - _active( - ActionId.PROJECT_REVISION_POLICY_UPDATE, - PermissionId.PROJECT_REVIEW_POLICY_MANAGE, - ActionOwner.XINT_003_02B, - ), + _active(ActionId.PROJECT_REVISION_POLICY_UPDATE, PermissionId.PROJECT_REVIEW_POLICY_MANAGE, ActionOwner.XINT_003_02B), _active(ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12E), _active(ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12E), - _active( - ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, - PermissionId.PROJECT_GUIDE_MANAGE, - ActionOwner.AUTH_12E, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F2, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F3, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F2, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ, - PermissionId.PROJECT_GUIDE_MANAGE, - ActionOwner.AUTH_12F4, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_CORRECTION_REQUEST, - PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, - ActionOwner.AUTH_12F4, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F4, - ), - _active( - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12G, - ), - _active( - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12G, - ), - _active( - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12G, - ), + _active(ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12E), + _active(ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12F2), + _active(ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12F3), + _active(ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12F2), + _active(ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12F4), + _active(ActionId.PROJECT_GUIDE_COMPILATION_CORRECTION_REQUEST, PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, ActionOwner.AUTH_12F4), + _active(ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12F4), + _active(ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12G), + _active(ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12G), + _active(ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12G), _active(ActionId.PROJECT_SETUP_RUN_UPDATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12B2), _active(ActionId.PROJECT_GUIDE_ACTIVATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12H), _active(ActionId.TASK_SUBMISSION_LIST, PermissionId.SUBMISSION_READ_OWN, ActionOwner.TASK_CHECKER_CLEANUP), @@ -656,16 +572,8 @@ def _active( _active(ActionId.PROJECT_TASK_RELEASE, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), _active(ActionId.PROJECT_TASK_REVISION_REPAIR, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_08), _active(ActionId.PROJECT_TASK_WORK_CONTEXT_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.TASK_PROJECT_GRANT), - _active( - ActionId.OPERATIONS_TASK_START_OVERRIDE, - PermissionId.OPERATIONS_TASK_START_OVERRIDE, - ActionOwner.TASK_PROJECT_GRANT, - ), - _planned( - ActionId.OPERATIONS_SUBMISSION_GATE_REPAIR, - PermissionId.OPERATIONS_SUBMISSION_GATE_REPAIR, - ActionOwner.AUTH_14, - ), + _active(ActionId.OPERATIONS_TASK_START_OVERRIDE, PermissionId.OPERATIONS_TASK_START_OVERRIDE, ActionOwner.TASK_PROJECT_GRANT), + _planned(ActionId.OPERATIONS_SUBMISSION_GATE_REPAIR, PermissionId.OPERATIONS_SUBMISSION_GATE_REPAIR, ActionOwner.AUTH_14), _active(ActionId.OPERATIONS_CHECKER_RETRY, PermissionId.OPERATIONS_CHECKER_RETRY, ActionOwner.AUTH_14), _active(ActionId.OPERATIONS_CHECKER_RETRY_SOURCE_LIST, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.AUTH_14), _active(ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_LIST, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.AUTH_14), @@ -684,28 +592,16 @@ def _active( _planned(ActionId.REVIEW_CHAIN_READ, PermissionId.REVIEW_CHAIN_READ, ActionOwner.AUTH_REV_07), _planned(ActionId.REVIEW_FINDING_EVIDENCE_INGEST, PermissionId.REVIEW_DECISION, ActionOwner.AUTH_REV_07), _planned(ActionId.REVIEW_DECISION, PermissionId.REVIEW_DECISION, ActionOwner.AUTH_REV_08), - _planned( - ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, - PermissionId.SUBMISSION_CREATE, - ActionOwner.AUTH_REV_09A, - ), + _planned(ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, PermissionId.SUBMISSION_CREATE, ActionOwner.AUTH_REV_09A), _planned(ActionId.REVIEW_LEASE_FORCE_RELEASE, PermissionId.REVIEW_LEASE_FORCE_RELEASE, ActionOwner.AUTH_REV_11), _planned(ActionId.REVIEW_QUEUE_ROUTING_OVERRIDE, PermissionId.REVIEW_QUEUE_OVERRIDE, ActionOwner.AUTH_REV_11), _planned(ActionId.REVIEW_QUEUE_ROUTING_CORRECT, PermissionId.REVIEW_QUEUE_OVERRIDE, ActionOwner.AUTH_REV_11), _planned(ActionId.REVIEW_QUEUE_CLOSE, PermissionId.REVIEW_QUEUE_OVERRIDE, ActionOwner.AUTH_REV_11), _planned(ActionId.REVIEW_RECONCILE_RUN, PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.AUTH_REV_11), - _planned( - ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE, - PermissionId.OPERATIONS_RECONCILE_RUN, - ActionOwner.AUTH_REV_12, - ), + _planned(ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE, PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.AUTH_REV_12), _planned(ActionId.REVIEW_PROJECTION_REBUILD, PermissionId.OPERATIONS_PROJECTION_REBUILD, ActionOwner.AUTH_REV_12), _planned(ActionId.REVIEW_REVISION_CONTEXT_REPAIR, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.XINT_003_08A), - _planned( - ActionId.REVIEW_REVISION_CONTEXT_LEGACY_CLOSE, - PermissionId.OPERATIONS_RECONCILE_RUN, - ActionOwner.XINT_003_08A, - ), + _planned(ActionId.REVIEW_REVISION_CONTEXT_LEGACY_CLOSE, PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.XINT_003_08A), _active(ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.XINT_003_08B), _active(ActionId.ARTIFACT_BINDING_READ, PermissionId.ARTIFACT_BINDING_READ, ActionOwner.AUTH_ART_02D_OPERATOR), _active(ActionId.ARTIFACT_REPLICA_READ, PermissionId.ARTIFACT_REPLICA_READ, ActionOwner.AUTH_ART_02D_OPERATOR), @@ -723,100 +619,28 @@ def _active( _active(ActionId.ARTIFACT_GUIDE_SOURCE_READ, PermissionId.ARTIFACT_GUIDE_SOURCE_READ, ActionOwner.XINT_002_04B), _active(ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, PermissionId.SUBMISSION_CREATE, ActionOwner.XINT_002_05A), _active(ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.AUTH_ART_05), - _active( - ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, - PermissionId.ARTIFACT_BINDING_CREATE, - ActionOwner.AUTH_ART_06B, - ), - _active( - ActionId.ARTIFACT_VERIFICATION_EXECUTE, - PermissionId.ARTIFACT_VERIFICATION_EXECUTE, - ActionOwner.AUTH_ART_02D_INTERNAL, - ), - _active( - ActionId.ARTIFACT_PENDING_WORK_SCAN, - PermissionId.ARTIFACT_PENDING_WORK_SCAN, - ActionOwner.AUTH_ART_02D_INTERNAL, - ), - _active( - ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, - PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, - ActionOwner.AUTH_ART_02D_INTERNAL, - ), + _active(ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.AUTH_ART_06B), + _active(ActionId.ARTIFACT_VERIFICATION_EXECUTE, PermissionId.ARTIFACT_VERIFICATION_EXECUTE, ActionOwner.AUTH_ART_02D_INTERNAL), + _active(ActionId.ARTIFACT_PENDING_WORK_SCAN, PermissionId.ARTIFACT_PENDING_WORK_SCAN, ActionOwner.AUTH_ART_02D_INTERNAL), + _active(ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionOwner.AUTH_ART_02D_INTERNAL), _active(ActionId.CHECKER_PRE_SUBMIT_EXECUTE, PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, ActionOwner.PILOT_04), _active(ActionId.CHECKER_PRE_SUBMIT_FINALIZE, PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, ActionOwner.PILOT_04), _active(ActionId.CHECKER_POST_SUBMIT_EXECUTE, PermissionId.CHECKER_POST_SUBMIT_EXECUTE, ActionOwner.ARCH_04D2), _active(ActionId.CHECKER_POST_SUBMIT_FINALIZE, PermissionId.CHECKER_POST_SUBMIT_FINALIZE, ActionOwner.ARCH_04D2), - _active( - ActionId.CHECKER_MODEL_CALL_PREPARE, - PermissionId.CHECKER_MODEL_CALL_PREPARE, - ActionOwner.PILOT_05, - ), - _active( - ActionId.CHECKER_MODEL_CALL_DISPATCH, - PermissionId.CHECKER_MODEL_CALL_DISPATCH, - ActionOwner.PILOT_05, - ), - _active( - ActionId.CHECKER_MODEL_CALL_RECOVER, - PermissionId.CHECKER_MODEL_CALL_RECOVER, - ActionOwner.PILOT_05, - ), - _active( - ActionId.CHECKER_MODEL_CALL_OBSERVE, - PermissionId.CHECKER_MODEL_CALL_OBSERVE, - ActionOwner.PILOT_05, - ), - _active( - ActionId.CHECKER_MODEL_CALL_COMPLETE, - PermissionId.CHECKER_MODEL_CALL_COMPLETE, - ActionOwner.PILOT_05, - ), - _active( - ActionId.CHECKER_MODEL_CALL_GRANT, - PermissionId.CHECKER_MODEL_CALL_GRANT, - ActionOwner.PILOT_05, - ), - _active( - ActionId.PROJECT_MODEL_PROFILE_PROVISION, - PermissionId.PROJECT_MODEL_PROFILE_MANAGE, - ActionOwner.PILOT_05, - ), - _active( - ActionId.PROJECT_MODEL_BUDGET_CONFIGURE, - PermissionId.PROJECT_MODEL_BUDGET_MANAGE, - ActionOwner.PILOT_05, - ), - _active( - ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, - PermissionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, - ActionOwner.PILOT_05, - ), - _active( - ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, - PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, - ActionOwner.XINT_002_06A, - ), - _active( - ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, - PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, - ActionOwner.AUTH_ART_06A, - ), - _active( - ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, - PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, - ActionOwner.AUTH_ART_06B, - ), - _planned( - ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, - PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, - ActionOwner.XINT_002_07, - ), - _planned( - ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE, - PermissionId.ARTIFACT_BINDING_CREATE, - ActionOwner.XINT_002_07, - ), + _active(ActionId.CHECKER_MODEL_CALL_PREPARE, PermissionId.CHECKER_MODEL_CALL_PREPARE, ActionOwner.PILOT_05), + _active(ActionId.CHECKER_MODEL_CALL_DISPATCH, PermissionId.CHECKER_MODEL_CALL_DISPATCH, ActionOwner.PILOT_05), + _active(ActionId.CHECKER_MODEL_CALL_RECOVER, PermissionId.CHECKER_MODEL_CALL_RECOVER, ActionOwner.PILOT_05), + _active(ActionId.CHECKER_MODEL_CALL_OBSERVE, PermissionId.CHECKER_MODEL_CALL_OBSERVE, ActionOwner.PILOT_05), + _active(ActionId.CHECKER_MODEL_CALL_COMPLETE, PermissionId.CHECKER_MODEL_CALL_COMPLETE, ActionOwner.PILOT_05), + _active(ActionId.CHECKER_MODEL_CALL_GRANT, PermissionId.CHECKER_MODEL_CALL_GRANT, ActionOwner.PILOT_05), + _active(ActionId.PROJECT_MODEL_PROFILE_PROVISION, PermissionId.PROJECT_MODEL_PROFILE_MANAGE, ActionOwner.PILOT_05), + _active(ActionId.PROJECT_MODEL_BUDGET_CONFIGURE, PermissionId.PROJECT_MODEL_BUDGET_MANAGE, ActionOwner.PILOT_05), + _active(ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, PermissionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, ActionOwner.PILOT_05), + _active(ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, ActionOwner.XINT_002_06A), + _active(ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, ActionOwner.AUTH_ART_06A), + _active(ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, ActionOwner.AUTH_ART_06B), + _planned(ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, ActionOwner.XINT_002_07), + _planned(ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE, PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.XINT_002_07), *( _active(action, PermissionId.COMPENSATION_ADAPTER_BINDING_MANAGE, ActionOwner.ARCH_CP01A) for action in ( @@ -830,11 +654,7 @@ def _active( _active(action, PermissionId.COMPENSATION_POLICY_MANAGE, ActionOwner.ARCH_CP01B) for action in _CONTRIBUTION_POLICY_ACTION_IDS ), - _active( - ActionId.CONTRIBUTION_COMPENSATION_UNIT_CREATE, - PermissionId.COMPENSATION_POLICY_MANAGE, - ActionOwner.PROJECT_POINTS_UNIT_CREATION, - ), + _active(ActionId.CONTRIBUTION_COMPENSATION_UNIT_CREATE, PermissionId.COMPENSATION_POLICY_MANAGE, ActionOwner.PROJECT_POINTS_UNIT_CREATION), ) PERMISSION_IDS = frozenset(PermissionId) diff --git a/backend/app/modules/authorization/checker_model_authorization.py b/backend/app/modules/authorization/checker_model_authorization.py index 0bc999b87..36019f7cb 100644 --- a/backend/app/modules/authorization/checker_model_authorization.py +++ b/backend/app/modules/authorization/checker_model_authorization.py @@ -202,14 +202,14 @@ async def authorize_observe( async def authorize_child( self, - execution: ModelCallExecutionFacts, + project_id: UUID, parent_attempt_id: UUID, terminal_digest: str, ) -> ModelActionEvidence: """Require explicit system Operator authority for one recovery child.""" actor_profile_id = self._require_human() facts = ModelCallRecoveryChildAuthorityFacts( - execution=execution, + project_id=project_id, parent_attempt_id=parent_attempt_id, terminal_digest=terminal_digest, ) @@ -304,25 +304,31 @@ async def _authorize( resource, error, ) + except AuthorizationEvidenceUnavailable as evidence_error: + raise ModelCallUnavailable( + "model_call_authority_evidence_unavailable" + ) from evidence_error except ( AuthorizationDenied, - AuthorizationEvidenceUnavailable, PreparedAuthorizationHandleInvalid, PreparedAuthorizationUnsupported, ): pass raise ModelCallUnavailable("model_call_authority_unavailable") from error - except ( - AuthorizationDenied, - AuthorizationEvidenceUnavailable, - PreparedAuthorizationHandleInvalid, - ) as error: + except AuthorizationDenied as error: if handle is not None: try: self._prepared.close_handle(handle) except PreparedAuthorizationHandleInvalid: pass raise ModelCallUnavailable("model_call_authority_unavailable") from error + except (AuthorizationEvidenceUnavailable, PreparedAuthorizationHandleInvalid) as error: + if handle is not None: + try: + self._prepared.close_handle(handle) + except PreparedAuthorizationHandleInvalid: + pass + raise ModelCallUnavailable("model_call_authority_evidence_unavailable") from error return ModelActionEvidence( evidence_id=decision.decision_id, facts_digest=service_facts_digest, diff --git a/backend/app/modules/authorization/checker_output_authorization.py b/backend/app/modules/authorization/checker_output_authorization.py index 4516773ce..5ead05b91 100644 --- a/backend/app/modules/authorization/checker_output_authorization.py +++ b/backend/app/modules/authorization/checker_output_authorization.py @@ -3,10 +3,12 @@ from __future__ import annotations from app.modules.actors.api import ServiceIdentity -from app.modules.artifacts.checker_output_bindings import CheckerOutputBindingAuthority -from app.modules.artifacts.checker_output_custody import CheckerOutputStoredFacts -from app.modules.artifacts.checker_outputs import CheckerOutputWriteAuthority -from app.modules.artifacts.schemas import CheckerOutputArtifactAdmissionRequest +from app.modules.artifacts.api.checker_output_authority import ( + CheckerOutputBindingAuthority, + CheckerOutputCommitmentAuthorityFacts, + CheckerOutputStoredFacts, + CheckerOutputWriteAuthority, +) from app.modules.authorization.catalogue import ( ACTION_BY_ID, SERVICE_ACTIONS_BY_IDENTITY, @@ -183,12 +185,19 @@ def __init__(self, session) -> None: service_identity=ServiceIdentity.ARTIFACT_CHECKER_OUTPUT, ) - async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: + async def consume(self, facts: CheckerOutputCommitmentAuthorityFacts) -> None: """Consume server-computed commitment facts without receiving raw bytes.""" + if type(facts) is not CheckerOutputCommitmentAuthorityFacts: + raise CheckerOutputUnavailable("checker_output_authority_unavailable") selector = self._require_selector() - if request.slot_key != selector.slot_key: + if facts.slot_key != selector.slot_key: raise CheckerOutputUnavailable("checker_output_authority_unavailable") - await self._consume(checker_output_admission_resource(selector, request)) + try: + resource = checker_output_admission_resource(selector, facts) + except (TypeError, ValueError) as error: + self.discard() + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + await self._consume(resource) async def consume_existing( self, @@ -197,7 +206,12 @@ async def consume_existing( ) -> None: """Consume exact stored ancestry selected under the prepared selector.""" selector = self._require_selector() - await self._consume(checker_output_stored_resource(WRITE, selector, reservation, facts)) + try: + resource = checker_output_stored_resource(WRITE, selector, reservation, facts) + except (TypeError, ValueError) as error: + self.discard() + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + await self._consume(resource) def _require_selector(self) -> CheckerOutputSelector: if self._selector is None: @@ -225,9 +239,12 @@ async def consume( """Consume only complete verified ancestry for the prepared selector.""" if self._selector is None: raise CheckerOutputUnavailable("checker_output_authority_unavailable") - await self._consume( - checker_output_stored_resource(BIND, self._selector, reservation, facts) - ) + try: + resource = checker_output_stored_resource(BIND, self._selector, reservation, facts) + except (TypeError, ValueError) as error: + self.discard() + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + await self._consume(resource) __all__ = ( diff --git a/backend/app/modules/authorization/domain/audit_targets.py b/backend/app/modules/authorization/domain/audit_targets.py index 5dc54eb34..8888dba56 100644 --- a/backend/app/modules/authorization/domain/audit_targets.py +++ b/backend/app/modules/authorization/domain/audit_targets.py @@ -65,6 +65,18 @@ def _compensation_unit_target(resource): return project_id, "project", project_id, "project", project_id +def _model_administration_target(resource): + """Audit project model administration against its exact owning project.""" + from app.modules.authorization.domain.checker_models import ( + ProjectModelAdministrationResourceContext, + ) + + if type(resource) is not ProjectModelAdministrationResourceContext: + return None + project_id = str(resource.project_id) + return project_id, "project", project_id, "project", project_id + + def project_authority_audit_target( resource: object, action_id: ActionId, *, matched_scope_project_id: UUID | None = None, @@ -136,7 +148,7 @@ def project_authority_audit_target( return project_id, "checker_run", str(resource.resource_id), "project", project_id if type(resource) is CheckerRetrySourceReadResourceContext: return _checker_retry_read_target(resource) - if target := _post_submit_delivery_target(resource) or _compensation_unit_target(resource): + if target := _post_submit_delivery_target(resource) or _compensation_unit_target(resource) or _model_administration_target(resource): return target if action_id in SUBMISSION_POLICY_MUTATIONS and isinstance( resource, ProjectSubmissionArtifactPolicyMutationResourceContext, diff --git a/backend/app/modules/authorization/domain/checker_models.py b/backend/app/modules/authorization/domain/checker_models.py index 489619643..b3640ca4d 100644 --- a/backend/app/modules/authorization/domain/checker_models.py +++ b/backend/app/modules/authorization/domain/checker_models.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from typing import Literal, Self from uuid import UUID @@ -101,7 +102,7 @@ class ModelCallRecoveryChildAuthorityFacts(BaseModel): """Operator-authorized child after one exact terminal parent effect.""" model_config = _STRICT_FROZEN - execution: ModelCallExecutionFacts + project_id: UUID parent_attempt_id: UUID terminal_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") @@ -192,6 +193,9 @@ def exact_operation(self) -> Self: **{action: CheckerModelCallResourceContext for action in CHECKER_MODEL_CALL_ACTIONS}, **{action: ProjectModelAdministrationResourceContext for action in MODEL_ADMIN_ACTIONS}, } +MODEL_ADMIN_RESOURCE_BY_ACTION = { + action: ProjectModelAdministrationResourceContext for action in MODEL_ADMIN_ACTIONS +} def checker_model_call_resource( @@ -234,7 +238,10 @@ def parse_checker_model_prepare( if expected is None: return None try: - resource = expected.model_validate(value) + # Prepared requests are retained as JSON-compatible values. Re-enter through + # JSON validation so strict UUID/datetime fields receive their wire types + # without weakening integer, boolean, enum or object validation. + resource = expected.model_validate_json(json.dumps(value)) if resource.action_id is not action: raise ValueError("checker model action mismatch") return resource @@ -296,7 +303,7 @@ def _admin_identity(facts: AdminFacts, actor_profile_id: UUID) -> tuple[UUID, UU if type(facts) in {ModelProfileProvisioningFacts, ModelBudgetProvisioningFacts}: return facts.spec.project_id, facts.operation_id, facts.actor_profile_id if type(facts) is ModelCallRecoveryChildAuthorityFacts: - return facts.execution.project_id, facts.parent_attempt_id, actor_profile_id + return facts.project_id, facts.parent_attempt_id, actor_profile_id raise ValueError("invalid project model administration facts") @@ -310,6 +317,7 @@ def _admin_identity(facts: AdminFacts, actor_profile_id: UUID) -> tuple[UUID, UU "DISPATCH", "GRANT", "MODEL_ADMIN_ACTIONS", + "MODEL_ADMIN_RESOURCE_BY_ACTION", "ModelCallCompleteAuthorityFacts", "ModelCallDispatchAuthorityFacts", "ModelCallGrantAuthorityFacts", diff --git a/backend/app/modules/authorization/domain/checker_outputs.py b/backend/app/modules/authorization/domain/checker_outputs.py index 5134192bd..f47b94d5a 100644 --- a/backend/app/modules/authorization/domain/checker_outputs.py +++ b/backend/app/modules/authorization/domain/checker_outputs.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from typing import Literal, Self from uuid import UUID @@ -146,10 +147,9 @@ def checker_output_prepare_resource( def checker_output_admission_resource( selector: CheckerOutputSelector, - request: object, + facts: object, ) -> CheckerOutputResourceContext: """Bind prepared write authority to server-computed content facts.""" - commitment = request.source.commitment prepared = checker_output_prepare_resource(WRITE, selector) return CheckerOutputResourceContext( resource_id=prepared.resource_id, @@ -157,11 +157,11 @@ def checker_output_admission_resource( action_id=prepared.action_id, selector=prepared.selector, selector_digest=prepared.selector_digest, - reservation=request.reservation, + reservation=facts.reservation, commitment=CheckerOutputCommitmentFacts( - sha256=commitment.sha256, - byte_count=commitment.byte_count, - media_type=commitment.media_type, + sha256=facts.sha256, + byte_count=facts.byte_count, + media_type=facts.media_type, ), ) @@ -217,7 +217,7 @@ def parse_checker_output_prepare( if action not in CHECKER_OUTPUT_ACTIONS: return None try: - resource = CheckerOutputResourceContext.model_validate(value) + resource = CheckerOutputResourceContext.model_validate_json(json.dumps(value)) if ( resource.action_id is not action or resource.reservation is not None diff --git a/backend/app/modules/authorization/domain/prepared_checker_runtime.py b/backend/app/modules/authorization/domain/prepared_checker_runtime.py new file mode 100644 index 000000000..f200cb076 --- /dev/null +++ b/backend/app/modules/authorization/domain/prepared_checker_runtime.py @@ -0,0 +1,65 @@ +"""Prepared-binding helpers for checker model calls and their ART outputs.""" + +from uuid import UUID + +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.checker_models import ( + CHECKER_MODEL_ACTIONS, + CheckerModelCallResourceContext, + ProjectModelAdministrationResourceContext, + checker_model_prepare_matches, + parse_checker_model_prepare, +) +from app.modules.authorization.domain.checker_outputs import ( + CHECKER_OUTPUT_ACTIONS, + CheckerOutputResourceContext, + checker_output_prepare_matches, + parse_checker_output_prepare, +) + + +def parse_checker_runtime_bindings( + action: ActionId, value: object, invalid_error: type[Exception] +) -> dict[str, object | None]: + """Parse the exact preliminary checker-runtime resource, when applicable.""" + return { + "checker_model_context": parse_checker_model_prepare(action, value, invalid_error), + "checker_output_context": parse_checker_output_prepare(action, value, invalid_error), + } + + +def checker_runtime_binding_matches( + action: ActionId, + model_context: object, + output_context: object, + resource: object, +) -> bool | None: + """Match a checker-runtime final resource or return None for another capability.""" + if action in CHECKER_MODEL_ACTIONS: + return checker_model_prepare_matches(model_context, resource) + if action in CHECKER_OUTPUT_ACTIONS: + return checker_output_prepare_matches(output_context, resource) + return None + + +def checker_runtime_project_id(action: ActionId, resource: object) -> UUID | None: + """Extract project scope only from the exact checker-runtime resource type.""" + if action in CHECKER_MODEL_ACTIONS and isinstance(resource, CheckerModelCallResourceContext): + return resource.scope_project_id + # Project model administration deliberately consumes system-scoped Operator + # authority. The project identifier remains an exact guarded target inside + # the resource; it is not the authority scope for this action family. + if action in CHECKER_MODEL_ACTIONS and isinstance( + resource, ProjectModelAdministrationResourceContext + ): + return None + if action in CHECKER_OUTPUT_ACTIONS and isinstance(resource, CheckerOutputResourceContext): + return resource.scope_project_id + return None + + +__all__ = ( + "checker_runtime_binding_matches", + "checker_runtime_project_id", + "parse_checker_runtime_bindings", +) diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 75d20abee..fabe20753 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -201,14 +201,8 @@ async def prelock_serialized_admin_read_actor( ) _ARTIFACT_INTERNAL_RESOURCES = { - ActionId.ARTIFACT_GUIDE_SOURCE_READ: ( - "guide_source_read", - GuideSourceReadResourceContext, - ), - ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( - "pre_submit_checker_input", - PreSubmitCheckerInputResourceContext, - ), + ActionId.ARTIFACT_GUIDE_SOURCE_READ: ("guide_source_read", GuideSourceReadResourceContext), + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: ("pre_submit_checker_input", PreSubmitCheckerInputResourceContext), ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE: ("submission_binding", SubmissionBindingResourceContext), ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ( "artifact_put_attempt", @@ -253,17 +247,12 @@ async def prelock_serialized_admin_read_actor( ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ: ProjectDiagnosticReadResourceContext, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST: ProjectDiagnosticReadResourceContext, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ: ProjectDiagnosticReadResourceContext, - ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ: ( - ProjectPolicyReadResourceContext - ), + ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ: ProjectPolicyReadResourceContext, ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ: ProjectPolicyReadResourceContext, ActionId.PROJECT_ACTIVE_GUIDE_READ: ProjectActiveGuideReadResourceContext, **adapter_bindings.ADAPTER_BINDING_RESOURCE_BY_ACTION, **contribution_policies.CONTRIBUTION_POLICY_RESOURCE_BY_ACTION, **checker_registry.CHECKER_REGISTRY_RESOURCE_BY_ACTION, - **{ - action: checker_models.ProjectModelAdministrationResourceContext - for action in checker_models.MODEL_ADMIN_ACTIONS - }, + **checker_models.MODEL_ADMIN_RESOURCE_BY_ACTION, ActionId.OPERATIONS_CHECKER_RETRY: checker_retry.CheckerRetryResourceContext, **post_submit_deliveries.POST_SUBMIT_DELIVERY_RESOURCE_BY_ACTION, **artifact_operator.ARTIFACT_OPERATOR_RESOURCE_BY_ACTION, **PROJECT_MUTATION_RESOURCE_BY_ACTION, @@ -961,12 +950,7 @@ async def _admin_denial( adapter_bindings.finance_authority_grant_filters(action.action_id) ) grant_filters.update({**contribution_policies.policy_finance_grant_filters(action.action_id), **checker_registry.checker_registry_grant_filters(action.action_id)}) - if action.action_id in ( - checker_retry.CHECKER_RETRY_ACTIONS - | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS - | checker_models.MODEL_ADMIN_ACTIONS - | artifact_operator.ARTIFACT_OPERATOR_ACTIONS - ): + if action.action_id in checker_retry.CHECKER_RETRY_ACTIONS | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS | checker_models.MODEL_ADMIN_ACTIONS | artifact_operator.ARTIFACT_OPERATOR_ACTIONS: grant_filters["allowed_roles"] = (AdminRole.OPERATOR,) matched = await self._admin.find_effective_grant( context.actor_profile_id, diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index bf0352039..5801800bd 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -8,7 +8,6 @@ from app.modules.authorization.fixed_service_revalidation import fixed_service_context_revalidator from app.modules.authorization.prepared_checker_retry_replay import validate_checker_retry_replay from app.modules.reviews.api.lifecycle import LifecycleTransitionCommand - from copy import Error as CopyError from contextlib import asynccontextmanager from dataclasses import dataclass @@ -47,19 +46,7 @@ checker_retry_resource_matches, parse_checker_retry_prepare, ) -from app.modules.authorization.domain.checker_models import ( - CHECKER_MODEL_ACTIONS, - CheckerModelCallResourceContext, - ProjectModelAdministrationResourceContext, - checker_model_prepare_matches, - parse_checker_model_prepare, -) -from app.modules.authorization.domain.checker_outputs import ( - CHECKER_OUTPUT_ACTIONS, - CheckerOutputResourceContext, - checker_output_prepare_matches, - parse_checker_output_prepare, -) +from app.modules.authorization.domain.prepared_checker_runtime import checker_runtime_binding_matches, checker_runtime_project_id, parse_checker_runtime_bindings from app.modules.checkers.api.execution import CheckerRetryPreparation from app.modules.authorization.domain.prepared_submission_policy import parse_submission_policy_prepare from app.modules.authorization.domain.prepared_compilation import prepared_compilation_matches @@ -285,12 +272,8 @@ class _PreparedAuthorizationBinding: adapter_binding_resource_digest: str | None = None guide_projection_prepare_context: dict | None = None setup_finalization_prepare_context: dict | None = None - checker_model_context: ( - CheckerModelCallResourceContext - | ProjectModelAdministrationResourceContext - | None - ) = None - checker_output_context: CheckerOutputResourceContext | None = None + checker_model_context: object | None = None + checker_output_context: object | None = None TASK_IMPORT_SOURCE_ACTIONS = frozenset({ @@ -681,20 +664,12 @@ def _validate_consumption( issuance.authority.context.identity_link_id, historical_checker_retry_link, ): raise PreparedAuthorizationHandleInvalid("invalid prepared checker retry authority") - if expected_action_id in CHECKER_MODEL_ACTIONS and not checker_model_prepare_matches( - issuance.binding.checker_model_context, - final_resource_context, - ): - raise PreparedAuthorizationHandleInvalid( - "invalid prepared checker model authority" - ) - if expected_action_id in CHECKER_OUTPUT_ACTIONS and not checker_output_prepare_matches( - issuance.binding.checker_output_context, - final_resource_context, - ): - raise PreparedAuthorizationHandleInvalid( - "invalid prepared checker output authority" - ) + checker_runtime_matches = checker_runtime_binding_matches( + expected_action_id, issuance.binding.checker_model_context, + issuance.binding.checker_output_context, final_resource_context, + ) + if checker_runtime_matches is False: + raise PreparedAuthorizationHandleInvalid("invalid prepared checker runtime authority") async def consume( self, @@ -999,16 +974,7 @@ def _binding( **parse_prepared_contribution_policy(action_id, caller_input.request_value), **parse_review_bindings(action_id, caller_input, scope, self._context), **setup_bindings, - checker_model_context=parse_checker_model_prepare( - action_id, - caller_input.request_value, - PreparedAuthorizationHandleInvalid, - ), - checker_output_context=parse_checker_output_prepare( - action_id, - caller_input.request_value, - PreparedAuthorizationHandleInvalid, - ), + **parse_checker_runtime_bindings(action_id, caller_input.request_value, PreparedAuthorizationHandleInvalid), ) @staticmethod @@ -1018,26 +984,14 @@ def _scope_from_resource( ) -> PreparedAuthorityScope: artifact_resource = _EXACT_ARTIFACT_RESOURCE_BY_ACTION.get(action_id) if artifact_resource is not None and isinstance(resource, artifact_resource[1]): - return PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, - artifact_resource_type=artifact_resource[0], - artifact_resource_id=resource.resource_id, - ) + return PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, artifact_resource_type=artifact_resource[0], artifact_resource_id=resource.resource_id) if isinstance(resource, (ProjectGuideProjectionResourceContext, PostPolicyResourceContext, OutboxDispatchResourceContext, AssignmentInvalidationResourceContext, RevisionExpiryResourceContext, RevisionRecoveryResourceContext, PreSubmitResourceContext, PostSubmitResourceContext, PostSubmitRoutingResourceContext)): return PreparedAuthorityScope( kind=PreparedAuthorityScopeKind.PROJECT, project_id=resource.scope_project_id, ) - if isinstance(resource, CheckerModelCallResourceContext): - return PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.PROJECT, - project_id=resource.scope_project_id, - ) - if isinstance(resource, CheckerOutputResourceContext): - return PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.PROJECT, - project_id=resource.scope_project_id, - ) + if checker_runtime_project := checker_runtime_project_id(action_id, resource): + return PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.PROJECT, project_id=checker_runtime_project) if action_id is ActionId.ACTOR_PROFILE_UPDATE_SELF and isinstance( resource, ActorSelfResourceContext ): @@ -1123,7 +1077,6 @@ def _scope_from_resource( ) raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") - def _admin_prepared_scope(action_id, resource): """Return the exact administrative prepared scope when applicable.""" if ( diff --git a/backend/app/modules/checkers/api/execution.py b/backend/app/modules/checkers/api/execution.py index 6cb47b74e..4600ce913 100644 --- a/backend/app/modules/checkers/api/execution.py +++ b/backend/app/modules/checkers/api/execution.py @@ -25,6 +25,7 @@ from app.modules.checkers.api.delivery_reconciliation import ( PostSubmitRequestDeliveryFacts, ) +from app.modules.checkers.api.model_calls import ModelRecoveryCandidate REQUEST_EVENT = "PostSubmissionEvaluationRequested" COMPLETION_EVENT = "PostSubmissionEvaluationCompleted" @@ -173,6 +174,7 @@ class CheckerRetrySourceItem(CheckerValue): completed_at: AwareDatetime retry_receipt_id: ResourceId | None = None successor: CheckerRetrySuccessor | None = None + model_recovery_candidates: tuple[ModelRecoveryCandidate, ...] = () @model_validator(mode="after") def exact_retry_state(self): diff --git a/backend/app/modules/checkers/api/model_calls.py b/backend/app/modules/checkers/api/model_calls.py index 27da84df8..f6092bb4c 100644 --- a/backend/app/modules/checkers/api/model_calls.py +++ b/backend/app/modules/checkers/api/model_calls.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime from typing import Annotated, Literal, Protocol, Self from uuid import RFC_4122, UUID @@ -12,6 +13,7 @@ Field, StrictInt, StrictStr, + field_validator, model_validator, ) @@ -59,6 +61,29 @@ def _require_uuid7(value: UUID, name: str) -> UUID: return value +def _public_uuid(value: object) -> object: + """Decode only the canonical UUID spelling that a JSON request can carry.""" + if isinstance(value, UUID) or type(value) is not str: + return value + try: + parsed = UUID(value) + except ValueError: + return value + if str(parsed) != value: + raise ValueError("UUID must use its canonical lowercase spelling") + return parsed + + +def _public_datetime(value: object) -> object: + """Decode an RFC 3339-compatible timestamp before strict aware validation.""" + if isinstance(value, datetime) or type(value) is not str: + return value + try: + return datetime.fromisoformat(value) + except ValueError: + return value + + class ModelProfileSelector(ModelCallValue): """Exact immutable profile identity retained in approved checker configuration.""" @@ -89,6 +114,8 @@ class ModelPricing(ModelCallValue): cached_input_nanodollars_per_token: Nanodollars output_nanodollars_per_token: Nanodollars + _decode_public_uuid = field_validator("pricing_policy_id", mode="before")(_public_uuid) + @model_validator(mode="after") def nonzero_and_canonical(self) -> Self: _require_uuid7(self.pricing_policy_id, "model pricing policy ID") @@ -122,6 +149,10 @@ class ProjectModelProfileSpec(ModelCallValue): call_slots: Literal[1] = 1 pricing: ModelPricing + _decode_public_uuids = field_validator("project_id", "registry_entry_id", mode="before")( + _public_uuid + ) + @model_validator(mode="after") def canonical_ids(self) -> Self: _require_uuid7(self.registry_entry_id, "external checker registry entry ID") @@ -190,6 +221,8 @@ class ProjectModelBudgetConfigurationSpec(ModelCallValue): configuration_version: StrictInt = Field(ge=1) lifetime_limit_nanodollars: Nanodollars + _decode_public_uuid = field_validator("project_id", mode="before")(_public_uuid) + @model_validator(mode="after") def positive_limit(self) -> Self: if self.lifetime_limit_nanodollars <= 0: @@ -242,6 +275,10 @@ class ModelPaidSlotKey(ModelCallValue): profile_digest: Sha256 slot_index: Literal[0] = 0 + _decode_public_uuids = field_validator( + "project_id", "assignment_id", "submission_id", "profile_id", mode="before" + )(_public_uuid) + @model_validator(mode="after") def canonical_profile(self) -> Self: _require_uuid7(self.profile_id, "model profile ID") @@ -267,34 +304,45 @@ class ModelCallExecutionFacts(ModelCallValue): worker_lease_generation: StrictInt = Field(ge=1) worker_lease_expires_at: AwareDatetime + _decode_public_uuids = field_validator( + "project_id", + "task_id", + "assignment_id", + "submission_id", + "evaluation_request_id", + "checker_run_id", + "worker_lease_id", + mode="before", + )(_public_uuid) + _decode_public_expiry = field_validator("worker_lease_expires_at", mode="before")( + _public_datetime + ) + class ModelRecoveryChildRequest(ModelCallValue): """Public Operator request for one exact known-terminal paid-call child.""" - execution: ModelCallExecutionFacts slot: ModelPaidSlotKey registry_entry_id: UUID terminal_digest: Sha256 request_sha256: Sha256 + _decode_public_uuid = field_validator("registry_entry_id", mode="before")(_public_uuid) + @model_validator(mode="after") - def exact_execution(self) -> Self: + def canonical_registry(self) -> Self: _require_uuid7(self.registry_entry_id, "external checker registry entry ID") - if ( - self.execution.project_id, - self.execution.assignment_id, - self.execution.submission_id, - self.execution.submission_version, - ) != ( - self.slot.project_id, - self.slot.assignment_id, - self.slot.submission_id, - self.slot.submission_version, - ): - raise ValueError("model recovery execution and slot do not match") return self +class ModelRecoveryCandidate(ModelRecoveryChildRequest): + """Exact terminal paid effect disclosed under existing Operator retry reads.""" + + parent_attempt_id: UUID + + _decode_public_parent = field_validator("parent_attempt_id", mode="before")(_public_uuid) + + class ModelCallEvidence(ModelCallValue): """Verified private ART identity; transcript bytes never cross this contract.""" @@ -468,7 +516,37 @@ async def read_exact( project_id: UUID, registry_entry_id: UUID, registry_entry_digest: str, - ) -> ProjectModelProfile: ... + ) -> ProjectModelProfile: + """Resolve the complete selector only for its exact project and registry digest.""" + ... + + +class ModelCallAdministrationPort(Protocol): + """Own exact public profile, budget and recovery-child mutations.""" + + async def provision_profile( + self, facts: ModelProfileProvisioningFacts + ) -> ProjectModelProfile: + """Authorize and retain immutable profile facts with exact operation replay.""" + ... + + async def provision_budget( + self, facts: ModelBudgetProvisioningFacts + ) -> ProjectModelBudgetConfiguration: + """Publish a finite lifetime limit without resetting reserved or settled spend.""" + ... + + async def prepare_recovery_child( + self, + slot: ModelPaidSlotKey, + *, + registry_entry_id: UUID, + parent_attempt_id: UUID, + terminal_digest: str, + request_sha256: str, + ) -> PreparedModelCallAttempt: + """Authorize a child of proven terminal failure without dispatching a provider call.""" + ... class ModelCallCurrentExecutionPort(Protocol): @@ -476,7 +554,15 @@ class ModelCallCurrentExecutionPort(Protocol): async def require_current( self, facts: ModelCallExecutionFacts, slot: ModelPaidSlotKey - ) -> None: ... + ) -> None: + """Require current TASK and checker lease custody before attaching a paid slot.""" + ... + + async def require_recovery_current( + self, slot: ModelPaidSlotKey, parent_attempt_id: UUID + ) -> None: + """Lock current TASK/Submission custody without requiring a live worker lease.""" + ... class ModelCallEvidencePort(Protocol): @@ -484,9 +570,13 @@ class ModelCallEvidencePort(Protocol): async def require_request( self, attempt: PreparedModelCallAttempt, evidence: ModelCallEvidence - ) -> None: ... + ) -> None: + """Verify the exact attempt owns the sealed request bytes before dispatch.""" + ... - async def require_response(self, attempt_id: UUID, evidence: ModelCallEvidence) -> None: ... + async def require_response(self, attempt_id: UUID, evidence: ModelCallEvidence) -> None: + """Verify sealed response evidence belongs to this attempt before settlement.""" + ... class ModelCallAuthorityPort(Protocol): @@ -494,52 +584,68 @@ class ModelCallAuthorityPort(Protocol): async def authorize_profile( self, facts: ModelProfileProvisioningFacts - ) -> ModelActionEvidence: ... + ) -> ModelActionEvidence: + """Bind profile publication authority to the project and complete immutable facts.""" + ... async def authorize_budget( self, facts: ModelBudgetProvisioningFacts - ) -> ModelActionEvidence: ... + ) -> ModelActionEvidence: + """Bind budget publication authority to the exact project and configuration.""" + ... async def authorize_prepare( self, execution: ModelCallExecutionFacts, slot: ModelPaidSlotKey, request_sha256: str, - ) -> ModelActionEvidence: ... + ) -> ModelActionEvidence: + """Authorize request preparation for the current execution and stable paid slot.""" + ... async def authorize_dispatch( self, execution: ModelCallExecutionFacts, attempt: PreparedModelCallAttempt, request_evidence: ModelCallEvidence, - ) -> ModelActionEvidence: ... + ) -> ModelActionEvidence: + """Authorize one provider effect using current execution and verified request evidence.""" + ... async def authorize_recover( self, execution: ModelCallExecutionFacts, effect: ModelCallEffectAuthorityFacts, - ) -> ModelActionEvidence: ... + ) -> ModelActionEvidence: + """Authorize inspection of an existing effect without permitting another create.""" + ... async def authorize_observe( self, effect: ModelCallEffectAuthorityFacts, provider_response_id: str | None, observation_digest: str, - ) -> ModelActionEvidence: ... + ) -> ModelActionEvidence: + """Authorize retention of exact provider observations independently of lease expiry.""" + ... async def authorize_child( self, - execution: ModelCallExecutionFacts, + project_id: UUID, parent_attempt_id: UUID, terminal_digest: str, - ) -> ModelActionEvidence: ... + ) -> ModelActionEvidence: + """Authorize a recovery child only against the exact retained terminal digest.""" + ... async def authorize_complete( self, effect: ModelCallEffectAuthorityFacts, response_evidence: ModelCallEvidence | None, terminal_digest: str, - ) -> ModelActionEvidence: ... + ) -> ModelActionEvidence: + """Authorize terminal accounting against the effect and its response evidence.""" + ... def model_usage_cost(profile: ProjectModelProfileSpec, usage: ModelCallUsage) -> int: @@ -579,6 +685,7 @@ def model_action_digest(action: str, facts: object) -> str: "ModelActionEvidence", "ModelBudgetProvisioningFacts", "ModelCallAuthorityPort", + "ModelCallAdministrationPort", "ModelCallBudgetUnavailable", "ModelCallConflict", "ModelCallCurrentExecutionPort", @@ -594,6 +701,7 @@ def model_action_digest(action: str, facts: object) -> str: "ModelPaidSlotKey", "ModelPricing", "ModelProfileProvisioningFacts", + "ModelRecoveryCandidate", "ModelRecoveryChildRequest", "ModelProfileReaderPort", "ModelProfileSelector", diff --git a/backend/app/modules/checkers/checker_retry.py b/backend/app/modules/checkers/checker_retry.py index 1e76f99fd..cbdcea493 100644 --- a/backend/app/modules/checkers/checker_retry.py +++ b/backend/app/modules/checkers/checker_retry.py @@ -32,6 +32,7 @@ from app.modules.checkers.models import ( CheckerRetryReceipt, CheckerRun, CheckerSubmissionFence, ) +from app.modules.checkers.model_calls.service import model_recovery_candidates from app.modules.outbox.api import ( OutboxAppendInput, OutboxAppendPort, OutboxAppendResult, OutboxIdempotencyConflict, OutboxInputError, OutboxPersistenceError, @@ -240,6 +241,10 @@ async def discover(self, query: CheckerRetrySourceQuery) -> CheckerRetrySourcePa statement.order_by(CheckerRun.created_at, CheckerRun.id).limit(query.limit + 1) ) ).mappings().all() + recovery_candidates = await model_recovery_candidates( + self._session, + tuple(UUID(row.source_run_id) for row in rows[: query.limit]), + ) items = [] for row in rows[: query.limit]: source = CheckerRetryDiscoverySource( @@ -285,6 +290,9 @@ async def discover(self, query: CheckerRetrySourceQuery) -> CheckerRetrySourcePa completed_at=row.completed_at, retry_receipt_id=receipt_id, successor=successor, + model_recovery_candidates=recovery_candidates.get( + UUID(row.source_run_id), () + ), )) next_cursor = None if len(rows) > query.limit: diff --git a/backend/app/modules/checkers/model_calls/custody.py b/backend/app/modules/checkers/model_calls/custody.py index d1aef0e38..9fa99b7f4 100644 --- a/backend/app/modules/checkers/model_calls/custody.py +++ b/backend/app/modules/checkers/model_calls/custody.py @@ -1,5 +1,8 @@ """Current invocation and call-stable private evidence custody.""" +import json +from uuid import UUID + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -29,6 +32,7 @@ ) from app.modules.checkers.models import CheckerRun from app.modules.checkers.model_calls.repository import ModelCallRepository +from app.modules.checkers.model_calls.grants import CheckerModelInvocationGrant class ModelCallCurrentExecution: @@ -37,6 +41,32 @@ class ModelCallCurrentExecution: def __init__(self, session: AsyncSession, *, tasks: EvaluationTaskGuard): self._session, self._tasks = session, tasks + @staticmethod + def _require_binding(request, slot: ModelPaidSlotKey) -> None: + if request.assignment_id != slot.assignment_id or ( + slot.project_id, + slot.submission_id, + slot.submission_version, + ) != (request.project_id, request.submission_id, request.submission_version): + raise ModelCallUnavailable("model_call_execution_unavailable") + bindings = [ + binding + for binding in request.policy.entries + if binding.requirement_id == slot.requirement_id + and binding.registry.entry_digest == slot.registry_entry_digest + and binding.configuration_sha256 == slot.configuration_sha256 + ] + if len(bindings) != 1: + raise ModelCallUnavailable("model_call_binding_unavailable") + configuration = bindings[0].configuration + if ( + configuration.get("model_profile_id") != str(slot.profile_id) + or type(configuration.get("model_profile_version")) is not int + or configuration.get("model_profile_version") != slot.profile_version + or configuration.get("model_profile_digest") != slot.profile_digest + ): + raise ModelCallUnavailable("model_call_profile_unavailable") + async def require_current( self, facts: ModelCallExecutionFacts, @@ -62,30 +92,9 @@ async def require_current( if run is None: raise ModelCallUnavailable("model_call_execution_unavailable") request = stored_request(run) - if request.assignment_id != facts.assignment_id or ( - slot.project_id, - slot.assignment_id, - slot.submission_id, - slot.submission_version, - ) != (facts.project_id, facts.assignment_id, facts.submission_id, facts.submission_version): + if request.assignment_id != facts.assignment_id: raise ModelCallUnavailable("model_call_execution_unavailable") - bindings = [ - binding - for binding in request.policy.entries - if binding.requirement_id == slot.requirement_id - and binding.registry.entry_digest == slot.registry_entry_digest - and binding.configuration_sha256 == slot.configuration_sha256 - ] - if len(bindings) != 1: - raise ModelCallUnavailable("model_call_binding_unavailable") - configuration = bindings[0].configuration - if ( - configuration.get("model_profile_id") != str(slot.profile_id) - or type(configuration.get("model_profile_version")) is not int - or configuration.get("model_profile_version") != slot.profile_version - or configuration.get("model_profile_digest") != slot.profile_digest - ): - raise ModelCallUnavailable("model_call_profile_unavailable") + self._require_binding(request, slot) try: if not await self._tasks.lock_evaluation_scope(request): raise ModelCallUnavailable("model_call_execution_unavailable") @@ -101,6 +110,55 @@ async def require_current( except CheckerExecutionUnavailable: raise ModelCallUnavailable("model_call_execution_unavailable") from None + async def require_recovery_current( + self, slot: ModelPaidSlotKey, parent_attempt_id: UUID + ) -> None: + """Lock current TASK/Submission custody for a terminal source, without a lease.""" + require_transaction(self._session) + slot = ModelPaidSlotKey.model_validate(slot) + repo = ModelCallRepository(self._session) + slot_row = await repo.slot(slot) + parent = await repo.attempt(parent_attempt_id) + grant = await self._session.scalar( + select(CheckerModelInvocationGrant).where( + CheckerModelInvocationGrant.selected_attempt_id == str(parent_attempt_id) + ) + ) + if ( + slot_row is None + or parent is None + or parent.slot_id != slot_row.id + or grant is None + or grant.project_id != str(slot.project_id) + or grant.paid_slot != slot.model_dump(mode="json") + or grant.selected_request_sha256 != parent.request_sha256 + ): + raise ModelCallUnavailable("model_call_recovery_unavailable") + run = await self._session.get(CheckerRun, grant.checker_run_id) + if ( + run is None + or run.task_id != slot_row.task_id + or run.status != "infrastructure_failed" + or run.failure_code is None + or run.completed_at is None + ): + raise ModelCallUnavailable("model_call_recovery_unavailable") + try: + execution = ModelCallExecutionFacts.model_validate_json( + json.dumps(grant.execution_facts) + ) + request = stored_request(run) + except (CheckerExecutionUnavailable, ValueError): + raise ModelCallUnavailable("model_call_recovery_unavailable") from None + if execution.checker_run_id != UUID(run.id) or execution.task_id != UUID(slot_row.task_id): + raise ModelCallUnavailable("model_call_recovery_unavailable") + self._require_binding(request, slot) + try: + if not await self._tasks.lock_evaluation_scope(request): + raise ModelCallUnavailable("model_call_recovery_unavailable") + except CheckerExecutionUnavailable: + raise ModelCallUnavailable("model_call_recovery_unavailable") from None + class CheckerOutputReservations: """Resolve two bounded ART roles under one immutable paid call identity.""" diff --git a/backend/app/modules/checkers/model_calls/grants.py b/backend/app/modules/checkers/model_calls/grants.py index 024efab8c..2dc0510ad 100644 --- a/backend/app/modules/checkers/model_calls/grants.py +++ b/backend/app/modules/checkers/model_calls/grants.py @@ -6,14 +6,10 @@ import secrets from uuid import UUID -from sqlalchemy import CheckConstraint, DateTime, ForeignKey, String, Uuid, select, func -from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Mapped, mapped_column -from datetime import datetime from app.core.identifiers import new_record_id -from app.db.base import Base from app.interfaces.external_checker_execution import ExternalCheckerModelGrant from app.modules.checkers.api.model_calls import ( ModelCallCurrentExecutionPort, @@ -28,67 +24,7 @@ ModelInvocationGrantFacts, ) from app.modules.checkers.model_calls.repository import require_model_call_transaction - - -class CheckerModelInvocationGrant(Base): - """No plaintext capability or transcript; exact immutable callback custody only.""" - - __tablename__ = "checker_model_invocation_grants" - __table_args__ = ( - CheckConstraint( - "(get_byte(uuid_send(id),6)>>4)=7 and (get_byte(uuid_send(id),8)&192)=128", - name="id_uuid7", - ), - CheckConstraint( - "token_hash ~ '^sha256:[0-9a-f]{64}$' and " - "external_request_digest ~ '^sha256:[0-9a-f]{64}$' and " - "(selected_request_sha256 is null or selected_request_sha256 ~ '^sha256:[0-9a-f]{64}$')", - name="digests", - ), - CheckConstraint( - "(consumed_at is null and selected_request_sha256 is null and selected_attempt_id is null) or " - "(consumed_at is not null and selected_request_sha256 is not null and selected_attempt_id is not null)", - name="consumption", - ), - ) - id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) - token_hash: Mapped[str] = mapped_column(String(71), nullable=False, unique=True) - project_id: Mapped[str] = mapped_column( - Uuid(as_uuid=False), - ForeignKey("projects.id", ondelete="RESTRICT"), - nullable=False, - ) - checker_run_id: Mapped[str] = mapped_column( - Uuid(as_uuid=False), - ForeignKey("checker_runs.id", ondelete="RESTRICT"), - nullable=False, - ) - registry_entry_id: Mapped[str] = mapped_column( - Uuid(as_uuid=False), - ForeignKey("external_checker_registry_entries.id", ondelete="RESTRICT"), - nullable=False, - ) - execution_facts: Mapped[dict] = mapped_column(JSONB, nullable=False) - paid_slot: Mapped[dict] = mapped_column(JSONB, nullable=False) - external_request_digest: Mapped[str] = mapped_column(String(71), nullable=False) - expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - issued_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.clock_timestamp(), - nullable=False, - ) - authorization_evidence_id: Mapped[str] = mapped_column( - Uuid(as_uuid=False), - ForeignKey("audit_events.id", ondelete="RESTRICT"), - nullable=False, - unique=True, - ) - selected_request_sha256: Mapped[str | None] = mapped_column(String(71)) - selected_attempt_id: Mapped[str | None] = mapped_column( - Uuid(as_uuid=False), - ForeignKey("checker_model_call_attempts.id", ondelete="RESTRICT"), - ) - consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) +from app.modules.checkers.model_calls.models import CheckerModelInvocationGrant def model_grant_token_hash(token: str) -> str: diff --git a/backend/app/modules/checkers/model_calls/models.py b/backend/app/modules/checkers/model_calls/models.py index 7027b0c93..d64601eb0 100644 --- a/backend/app/modules/checkers/model_calls/models.py +++ b/backend/app/modules/checkers/model_calls/models.py @@ -14,6 +14,7 @@ String, UniqueConstraint, Uuid, + func, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column @@ -440,11 +441,67 @@ class CheckerModelCallRecovery(Base): ) +class CheckerModelInvocationGrant(Base): + """No plaintext capability or transcript; exact immutable callback custody only.""" + + __tablename__ = "checker_model_invocation_grants" + __table_args__ = ( + CheckConstraint( + "(get_byte(uuid_send(id),6)>>4)=7 and (get_byte(uuid_send(id),8)&192)=128", + name="id_uuid7", + ), + CheckConstraint( + "token_hash ~ '^sha256:[0-9a-f]{64}$' and " + "external_request_digest ~ '^sha256:[0-9a-f]{64}$' and " + "(selected_request_sha256 is null or selected_request_sha256 ~ '^sha256:[0-9a-f]{64}$')", + name="digests", + ), + CheckConstraint( + "(consumed_at is null and selected_request_sha256 is null and selected_attempt_id is null) or " + "(consumed_at is not null and selected_request_sha256 is not null and selected_attempt_id is not null)", + name="consumption", + ), + ) + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + token_hash: Mapped[str] = mapped_column(String(71), nullable=False, unique=True) + project_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False + ) + checker_run_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("checker_runs.id", ondelete="RESTRICT"), nullable=False + ) + registry_entry_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("external_checker_registry_entries.id", ondelete="RESTRICT"), + nullable=False, + ) + execution_facts: Mapped[dict] = mapped_column(JSONB, nullable=False) + paid_slot: Mapped[dict] = mapped_column(JSONB, nullable=False) + external_request_digest: Mapped[str] = mapped_column(String(71), nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + issued_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.clock_timestamp(), nullable=False + ) + authorization_evidence_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("audit_events.id", ondelete="RESTRICT"), + nullable=False, + unique=True, + ) + selected_request_sha256: Mapped[str | None] = mapped_column(String(71)) + selected_attempt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("checker_model_call_attempts.id", ondelete="RESTRICT"), + ) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + __all__ = ( "CheckerModelBudgetAccount", "CheckerModelBudgetConfiguration", "CheckerModelCallAttempt", "CheckerModelCallRecovery", "CheckerModelCallSlot", + "CheckerModelInvocationGrant", "CheckerModelProfile", ) diff --git a/backend/app/modules/checkers/model_calls/profiles.py b/backend/app/modules/checkers/model_calls/profiles.py index cf7accd81..bee8c05dd 100644 --- a/backend/app/modules/checkers/model_calls/profiles.py +++ b/backend/app/modules/checkers/model_calls/profiles.py @@ -61,9 +61,11 @@ class ModelProfileReader: """Read exact immutable metadata without profile-mutation authority.""" def __init__(self, session: AsyncSession) -> None: + """Bind exact profile reads to the caller-owned database session.""" self._session = session def _require_root_transaction(self) -> None: + """Reject reads outside an active root transaction or inside a savepoint.""" transaction = self._session.sync_session.get_transaction() if ( transaction is None diff --git a/backend/app/modules/checkers/model_calls/repository.py b/backend/app/modules/checkers/model_calls/repository.py index 6ef0a056a..e2f5c5115 100644 --- a/backend/app/modules/checkers/model_calls/repository.py +++ b/backend/app/modules/checkers/model_calls/repository.py @@ -16,6 +16,7 @@ CheckerModelCallSlot, CheckerModelProfile, ) +from app.modules.checkers.models import ExternalCheckerRegistryEntryRecord def require_model_call_transaction(session: AsyncSession) -> None: @@ -29,12 +30,15 @@ class ModelCallRepository: """Own model-call row locks without committing the caller's transaction.""" def __init__(self, session: AsyncSession) -> None: + """Bind all reads, writes, and locks to the caller-owned session.""" self._session = session async def now(self): + """Read the authoritative database clock inside the current transaction.""" return await self._session.scalar(select(func.clock_timestamp())) async def advisory(self, key: str) -> None: + """Take a transaction-scoped advisory lock for one canonical owner key.""" await self._session.execute( text("select pg_advisory_xact_lock(hashtextextended(:key, 0))"), {"key": key}, @@ -43,18 +47,34 @@ async def advisory(self, key: str) -> None: async def lock_profile_scopes( self, operation_id: UUID, project_id: UUID, profile_version: int ) -> None: + """Serialize profile idempotency and project version allocation in order.""" for key in ( f"checker-model-profile-operation:{operation_id}", f"checker-model-profile:{project_id}", ): await self.advisory(key) + async def is_exact_post_submit_registry_entry( + self, registry_entry_id: UUID, registry_entry_digest: str + ) -> bool: + """Resolve the profile target against immutable POST registry custody.""" + entry_id = await self._session.scalar( + select(ExternalCheckerRegistryEntryRecord.id).where( + ExternalCheckerRegistryEntryRecord.id == registry_entry_id, + ExternalCheckerRegistryEntryRecord.entry_digest == registry_entry_digest, + ExternalCheckerRegistryEntryRecord.phase == "post_submit", + ) + ) + return entry_id is not None + async def profile_by_operation(self, operation_id: UUID): + """Return the immutable profile previously written for an operation.""" return await self._session.scalar( select(CheckerModelProfile).where(CheckerModelProfile.operation_id == str(operation_id)) ) async def profile_by_version(self, project_id: UUID, profile_version: int): + """Return the profile occupying one project-scoped version, if present.""" return await self._session.scalar( select(CheckerModelProfile).where( CheckerModelProfile.project_id == str(project_id), @@ -63,6 +83,7 @@ async def profile_by_version(self, project_id: UUID, profile_version: int): ) async def latest_profile(self, project_id: UUID): + """Return the highest immutable profile version currently stored for a project.""" return await self._session.scalar( select(CheckerModelProfile) .where(CheckerModelProfile.project_id == str(project_id)) @@ -71,6 +92,7 @@ async def latest_profile(self, project_id: UUID): ) async def profile_exact(self, profile_id: UUID, profile_version: int, profile_digest: str): + """Resolve a profile only when its ID, version, and digest all match.""" return await self._session.scalar( select(CheckerModelProfile).where( CheckerModelProfile.id == str(profile_id), @@ -80,9 +102,11 @@ async def profile_exact(self, profile_id: UUID, profile_version: int, profile_di ) async def lock_budget_scope(self, project_id: UUID) -> None: + """Serialize lifetime budget mutations for one project.""" await self.advisory(f"checker-model-budget:{project_id}") async def budget_account(self, project_id: UUID, *, lock: bool = False): + """Load the project lifetime account, optionally taking its row lock.""" query = select(CheckerModelBudgetAccount).where( CheckerModelBudgetAccount.project_id == str(project_id) ) @@ -91,6 +115,7 @@ async def budget_account(self, project_id: UUID, *, lock: bool = False): return await self._session.scalar(query.execution_options(populate_existing=True)) async def budget_configuration_by_operation(self, operation_id: UUID): + """Return the immutable budget configuration written for an operation.""" return await self._session.scalar( select(CheckerModelBudgetConfiguration).where( CheckerModelBudgetConfiguration.operation_id == str(operation_id) @@ -98,6 +123,7 @@ async def budget_configuration_by_operation(self, operation_id: UUID): ) async def budget_configuration_by_version(self, project_id: UUID, version: int): + """Return one exact project budget version, if it exists.""" return await self._session.scalar( select(CheckerModelBudgetConfiguration).where( CheckerModelBudgetConfiguration.project_id == str(project_id), @@ -106,6 +132,7 @@ async def budget_configuration_by_version(self, project_id: UUID, version: int): ) async def current_budget_configuration(self, account: CheckerModelBudgetAccount): + """Refresh the configuration selected by the locked lifetime account.""" return await self._session.get( CheckerModelBudgetConfiguration, account.current_configuration_id, @@ -114,6 +141,7 @@ async def current_budget_configuration(self, account: CheckerModelBudgetAccount) @staticmethod def _slot_predicates(key: ModelPaidSlotKey): + """Build the complete paid-slot identity predicate without partial matching.""" return ( CheckerModelCallSlot.project_id == str(key.project_id), CheckerModelCallSlot.assignment_id == str(key.assignment_id), @@ -129,15 +157,18 @@ def _slot_predicates(key: ModelPaidSlotKey): ) async def lock_slot_scope(self, key: ModelPaidSlotKey) -> None: + """Serialize creation and state transitions for one exact paid slot.""" await self.advisory("checker-model-call-slot:" + key.digest) async def slot(self, key: ModelPaidSlotKey, *, lock: bool = False): + """Load an exact paid slot and optionally lock it for mutation.""" query = select(CheckerModelCallSlot).where(*self._slot_predicates(key)) if lock: query = query.with_for_update() return await self._session.scalar(query.execution_options(populate_existing=True)) async def latest_attempt(self, slot_id: str, *, lock: bool = False): + """Load the greatest attempt number for a slot, optionally for update.""" query = ( select(CheckerModelCallAttempt) .where(CheckerModelCallAttempt.slot_id == slot_id) @@ -149,6 +180,7 @@ async def latest_attempt(self, slot_id: str, *, lock: bool = False): return await self._session.scalar(query.execution_options(populate_existing=True)) async def attempt(self, attempt_id: UUID, *, lock: bool = False): + """Load one server-owned attempt ID, optionally refreshing it under lock.""" query = select(CheckerModelCallAttempt).where(CheckerModelCallAttempt.id == str(attempt_id)) if lock: query = query.with_for_update() @@ -161,6 +193,7 @@ async def recovery( worker_lease_id: UUID, worker_lease_generation: int, ): + """Resolve an attachment by its complete attempt, run, and worker lease identity.""" return await self._session.scalar( select(CheckerModelCallRecovery).where( CheckerModelCallRecovery.attempt_id == str(attempt_id), @@ -171,10 +204,12 @@ async def recovery( ) async def add(self, row) -> None: + """Stage and flush a new owned row without committing the root transaction.""" self._session.add(row) await self._session.flush() async def flush(self) -> None: + """Flush pending state transitions while leaving commit to the caller.""" await self._session.flush() diff --git a/backend/app/modules/checkers/model_calls/service.py b/backend/app/modules/checkers/model_calls/service.py index 30efc19b3..55942760d 100644 --- a/backend/app/modules/checkers/model_calls/service.py +++ b/backend/app/modules/checkers/model_calls/service.py @@ -5,7 +5,9 @@ from uuid import UUID from pydantic import TypeAdapter +from sqlalchemy import exists, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id @@ -31,6 +33,7 @@ ModelPaidSlotKey, ModelProfileProvisioningFacts, ModelProfileSelector, + ModelRecoveryCandidate, PreparedModelCallAttempt, ProviderResponseIdentifier, ProjectModelBudgetConfiguration, @@ -55,10 +58,12 @@ def _uuid(value: str) -> UUID: + """Decode a persisted UUID string at the typed service boundary.""" return UUID(value) def _same_uuid(value: str, expected: UUID) -> bool: + """Compare persisted string identity with a typed caller UUID exactly.""" return value == str(expected) @@ -67,6 +72,7 @@ def _same_uuid(value: str, expected: UUID) -> bool: def _sha(value: str) -> str: + """Require one lowercase SHA-256 identifier for raw service arguments.""" # Pydantic constrained aliases are validated by their containing public values; # raw service arguments receive the equivalent closed check here. if len(value) != 71 or not value.startswith("sha256:"): @@ -81,11 +87,13 @@ def _sha(value: str) -> str: def _require_authority(action: str, facts: object, evidence: ModelActionEvidence) -> None: + """Require authority evidence to bind the exact action and canonical facts.""" if evidence.facts_digest != model_action_digest(action, facts): raise ModelCallUnavailable("model_call_authority_unavailable") def _slot_key(row: CheckerModelCallSlot) -> ModelPaidSlotKey: + """Project a stored slot through the complete immutable paid-slot identity.""" return ModelPaidSlotKey( project_id=_uuid(row.project_id), assignment_id=_uuid(row.assignment_id), @@ -102,6 +110,7 @@ def _slot_key(row: CheckerModelCallSlot) -> ModelPaidSlotKey: def _evidence(row: CheckerModelCallAttempt, prefix: str) -> ModelCallEvidence: + """Decode one complete request or response ART evidence set from an attempt.""" values = { "binding_id": getattr(row, f"{prefix}_binding_id"), "content_id": getattr(row, f"{prefix}_content_id"), @@ -128,6 +137,7 @@ def _attempt( *, replayed: bool, ) -> AttachedModelCallAttempt: + """Project a stored attempt with its exact slot identity and replay status.""" values = dict( slot_id=_uuid(slot.id), slot_key=_slot_key(slot), @@ -146,6 +156,7 @@ def _attempt( def _effect( slot: CheckerModelCallSlot, row: CheckerModelCallAttempt ) -> ModelCallEffectAuthorityFacts: + """Build the authority facts identifying one durable provider effect.""" return ModelCallEffectAuthorityFacts( project_id=_uuid(slot.project_id), task_id=_uuid(slot.task_id), @@ -161,11 +172,13 @@ def _require_effect( slot: CheckerModelCallSlot, row: CheckerModelCallAttempt, ) -> None: + """Reject supplied effect authority facts that differ from stored custody.""" if supplied != _effect(slot, row): raise ModelCallUnavailable("model_call_effect_unavailable") def _usage(row: CheckerModelCallAttempt) -> ModelCallUsage | None: + """Return complete stored usage, distinguishing absent from partial counters.""" values = ( row.input_tokens, row.cached_input_tokens, @@ -184,6 +197,107 @@ def _usage(row: CheckerModelCallAttempt) -> ModelCallUsage | None: ) +def _stored_retry_terminal_digest(row: CheckerModelCallAttempt) -> str | None: + """Reconstruct a retry-eligible terminal digest only from complete stored facts.""" + if row.state == "known_no_effect": + if ( + row.no_effect_proof_id is None + or row.no_effect_proof_digest is None + or row.no_effect_observed_at is None + or row.reserved_nanodollars is None + ): + return None + return canonical_json_hash( + { + "state": row.state, + "proof_id": row.no_effect_proof_id, + "proof_digest": row.no_effect_proof_digest, + "observed_at": row.no_effect_observed_at.isoformat(), + } + ) + if row.state == "known_failed": + usage = _usage(row) + if ( + usage is None + or row.provider_response_id is None + or row.response_content_sha256 is None + or row.settled_nanodollars is None + or row.settlement_authorization_evidence_id is None + or row.settled_at is None + ): + return None + return canonical_json_hash( + { + "state": row.state, + "provider_response_id": row.provider_response_id, + "response_content_sha256": row.response_content_sha256, + "usage": usage.model_dump(mode="json"), + "settled_nanodollars": row.settled_nanodollars, + } + ) + return None + + +async def model_recovery_candidates( + session: AsyncSession, source_run_ids: tuple[UUID, ...] +) -> dict[UUID, tuple[ModelRecoveryCandidate, ...]]: + """Project retry-read projection of exact retryable paid effects.""" + if not source_run_ids: + return {} + from app.modules.checkers.model_calls.grants import CheckerModelInvocationGrant + + child = aliased(CheckerModelCallAttempt) + rows = ( + await session.execute( + select( + CheckerModelInvocationGrant.checker_run_id, + CheckerModelCallAttempt, + CheckerModelCallSlot, + CheckerModelProfile.registry_entry_id, + ) + .join( + CheckerModelCallAttempt, + CheckerModelCallAttempt.id + == CheckerModelInvocationGrant.selected_attempt_id, + ) + .join(CheckerModelCallSlot, CheckerModelCallSlot.id == CheckerModelCallAttempt.slot_id) + .join(CheckerModelProfile, CheckerModelProfile.id == CheckerModelCallSlot.profile_id) + .where( + CheckerModelInvocationGrant.checker_run_id.in_( + tuple(str(value) for value in source_run_ids) + ), + CheckerModelCallAttempt.state.in_(("known_failed", "known_no_effect")), + ~exists( + select(child.id).where( + child.parent_attempt_id == CheckerModelCallAttempt.id + ) + ), + ) + .order_by( + CheckerModelInvocationGrant.checker_run_id, + CheckerModelCallAttempt.id, + ) + ) + ).all() + projected: dict[UUID, dict[UUID, ModelRecoveryCandidate]] = {} + for run_id, attempt, slot, registry_entry_id in rows: + digest = _stored_retry_terminal_digest(attempt) + if digest is None: + continue + candidate = ModelRecoveryCandidate( + parent_attempt_id=_uuid(attempt.id), + slot=_slot_key(slot), + registry_entry_id=_uuid(registry_entry_id), + terminal_digest=digest, + request_sha256=attempt.request_sha256, + ) + projected.setdefault(_uuid(run_id), {})[candidate.parent_attempt_id] = candidate + return { + run_id: tuple(candidates.values()) + for run_id, candidates in projected.items() + } + + class ModelCallService: """Flush-only state machine; the caller owns commit or rollback.""" @@ -195,6 +309,7 @@ def __init__( current_execution: ModelCallCurrentExecutionPort, evidence: ModelCallEvidencePort, ) -> None: + """Bind persistence and authority ports without taking transaction ownership.""" self._session = session self._repo = ModelCallRepository(session) self._authority = authority @@ -202,6 +317,7 @@ def __init__( self._evidence = evidence def _require_transaction(self) -> None: + """Require the caller's active root transaction before state-machine work.""" require_model_call_transaction(self._session) async def provision_profile(self, facts: ModelProfileProvisioningFacts) -> ProjectModelProfile: @@ -211,6 +327,10 @@ async def provision_profile(self, facts: ModelProfileProvisioningFacts) -> Proje _ = facts.spec.maximum_reservation_nanodollars authority = await self._authority.authorize_profile(facts) _require_authority("profile", facts, authority) + if not await self._repo.is_exact_post_submit_registry_entry( + facts.spec.registry_entry_id, facts.spec.registry_entry_digest + ): + raise ModelCallConflict("model_profile_registry_conflict") await self._repo.lock_profile_scopes( facts.operation_id, facts.spec.project_id, facts.spec.profile_version @@ -344,6 +464,7 @@ async def provision_budget( def _budget_result( row: CheckerModelBudgetConfiguration, account: CheckerModelBudgetAccount ) -> ProjectModelBudgetConfiguration: + """Project an immutable budget version with its current lifetime counters.""" return ProjectModelBudgetConfiguration( configuration_id=_uuid(row.id), operation_id=_uuid(row.operation_id), @@ -387,6 +508,7 @@ async def read_exact( @staticmethod def _require_execution_slot(execution: ModelCallExecutionFacts, slot: ModelPaidSlotKey) -> None: + """Require the paid slot to belong to the exact authorized execution.""" if ( execution.project_id != slot.project_id or execution.assignment_id != slot.assignment_id @@ -398,6 +520,7 @@ def _require_execution_slot(execution: ModelCallExecutionFacts, slot: ModelPaidS async def _profile_for_slot( self, slot: ModelPaidSlotKey, registry_entry_id: UUID ) -> ProjectModelProfile: + """Resolve the exact stored profile and registry entry selected by a new slot.""" return await self.read_exact( ModelProfileSelector( profile_id=slot.profile_id, @@ -410,6 +533,7 @@ async def _profile_for_slot( ) async def _profile_for_stored_slot(self, slot: ModelPaidSlotKey) -> ProjectModelProfile: + """Resolve a stored slot's immutable profile without accepting registry substitution.""" row = await self._repo.profile_exact( slot.profile_id, slot.profile_version, slot.profile_digest ) @@ -482,7 +606,6 @@ async def prepare_attempt( async def prepare_recovery_child( self, - execution: ModelCallExecutionFacts, slot: ModelPaidSlotKey, *, registry_entry_id: UUID, @@ -492,21 +615,19 @@ async def prepare_recovery_child( ) -> PreparedModelCallAttempt: """Allocate N+1 only after an authorized, fully-known terminal parent.""" self._require_transaction() - execution = ModelCallExecutionFacts.model_validate(execution) slot = ModelPaidSlotKey.model_validate(slot) terminal_digest = _sha(terminal_digest) request_sha256 = _sha(request_sha256) - self._require_execution_slot(execution, slot) authority = await self._authority.authorize_child( - execution, parent_attempt_id, terminal_digest + slot.project_id, parent_attempt_id, terminal_digest ) child_facts = { - "execution": execution.model_dump(mode="json"), + "project_id": str(slot.project_id), "parent_attempt_id": str(parent_attempt_id), "terminal_digest": terminal_digest, } _require_authority("child", child_facts, authority) - await self._current.require_current(execution, slot) + await self._current.require_recovery_current(slot, parent_attempt_id) await self._profile_for_slot(slot, registry_entry_id) await self._repo.lock_slot_scope(slot) @@ -518,6 +639,17 @@ async def prepare_recovery_child( latest = await self._repo.latest_attempt(slot_row.id, lock=True) if latest is None: raise ModelCallUnavailable("model_call_parent_unavailable") + parent = ( + latest + if latest.id == str(parent_attempt_id) + else await self._repo.attempt(parent_attempt_id) + ) + if ( + parent is None + or parent.slot_id != slot_row.id + or not self._eligible_parent(parent, terminal_digest) + ): + raise ModelCallConflict("model_call_parent_not_retryable") if latest.id != str(parent_attempt_id): if ( latest.attempt_number > 1 @@ -529,8 +661,6 @@ async def prepare_recovery_child( _attempt(slot_row, latest, replayed=True) ) raise ModelCallConflict("model_call_parent_superseded") - if not self._eligible_parent(latest, terminal_digest): - raise ModelCallConflict("model_call_parent_not_retryable") child = CheckerModelCallAttempt( id=str(new_record_id()), @@ -547,28 +677,8 @@ async def prepare_recovery_child( @staticmethod def _eligible_parent(row: CheckerModelCallAttempt, terminal_digest: str) -> bool: - if row.state == "known_no_effect": - actual = canonical_json_hash( - { - "state": row.state, - "proof_id": row.no_effect_proof_id, - "proof_digest": row.no_effect_proof_digest, - "observed_at": ( - row.no_effect_observed_at.isoformat() - if row.no_effect_observed_at is not None - else None - ), - } - ) - return row.reserved_nanodollars is not None and actual == terminal_digest - if row.state == "known_failed": - result = ModelCallService._failed_result(row, replayed=False) - return ( - row.settlement_authorization_evidence_id is not None - and row.settled_at is not None - and model_retry_terminal_digest(result) == terminal_digest - ) - return False + """Accept a retry parent only when complete stored terminal facts match the digest.""" + return _stored_retry_terminal_digest(row) == terminal_digest async def reserve_dispatch( self, @@ -648,6 +758,7 @@ async def reserve_dispatch( def _set_evidence( row: CheckerModelCallAttempt, prefix: str, evidence: ModelCallEvidence ) -> None: + """Copy validated ART custody fields into the selected attempt evidence side.""" for field, value in evidence.model_dump(mode="json", exclude={"media_type"}).items(): setattr(row, f"{prefix}_{field}", value) @@ -658,6 +769,7 @@ def _dispatch_result( slot: CheckerModelCallSlot | None = None, replayed: bool, ) -> ModelDispatchReservation: + """Project a dispatch reservation only from effect-bearing stored states.""" if row.state not in {"dispatching", "uncertain", "completed"}: raise ModelCallConflict("model_call_not_dispatchable") if slot is None: @@ -1032,6 +1144,7 @@ async def mark_known_no_effect( async def _terminal_preflight( self, attempt_id: UUID ) -> tuple[CheckerModelCallAttempt, CheckerModelCallSlot, ProjectModelProfile]: + """Load the attempt, slot, and exact profile before taking terminal locks.""" self._require_transaction() row = await self._repo.attempt(attempt_id) if row is None: @@ -1049,6 +1162,7 @@ async def _terminal_preflight( async def _lock_terminal( self, slot: CheckerModelCallSlot, attempt_id: UUID ) -> CheckerModelCallAttempt: + """Take slot-order custody, then lock the attempt and recheck its ancestry.""" await self._repo.lock_slot_scope(_slot_key(slot)) row = await self._repo.attempt(attempt_id, lock=True) if row is None or row.slot_id != slot.id: @@ -1057,6 +1171,7 @@ async def _lock_terminal( @staticmethod def _require_effect_pending(row: CheckerModelCallAttempt) -> None: + """Require an effect-bearing nonterminal attempt with an active reservation.""" if row.state not in {"dispatching", "uncertain"}: raise ModelCallConflict("model_call_terminal_conflict") if row.reserved_nanodollars is None: @@ -1068,6 +1183,7 @@ async def _settle( slot: CheckerModelCallSlot, cost: int, ) -> None: + """Atomically move one reservation into the project's lifetime settled total.""" await self._repo.lock_budget_scope(_uuid(slot.project_id)) account = await self._repo.budget_account(_uuid(slot.project_id), lock=True) if account is None or row.reserved_nanodollars is None: @@ -1078,6 +1194,7 @@ async def _settle( account.lifetime_settled_nanodollars += cost async def _release(self, row: CheckerModelCallAttempt, slot: CheckerModelCallSlot) -> None: + """Return a proven no-effect reservation to the locked project account.""" await self._repo.lock_budget_scope(_uuid(slot.project_id)) account = await self._repo.budget_account(_uuid(slot.project_id), lock=True) if account is None or row.reserved_nanodollars is None: @@ -1088,6 +1205,7 @@ async def _release(self, row: CheckerModelCallAttempt, slot: CheckerModelCallSlo @staticmethod def _set_usage(row: CheckerModelCallAttempt, usage: ModelCallUsage) -> None: + """Persist every normalized usage counter as one inseparable terminal set.""" row.input_tokens = usage.input_tokens row.cached_input_tokens = usage.cached_input_tokens row.output_tokens = usage.output_tokens @@ -1095,6 +1213,7 @@ def _set_usage(row: CheckerModelCallAttempt, usage: ModelCallUsage) -> None: @staticmethod def _completed_result(row: CheckerModelCallAttempt, *, replayed: bool) -> CompletedModelCall: + """Project a completed call only when usage, evidence, cost, and time are complete.""" usage = _usage(row) if usage is None or row.terminal_at is None or row.settled_nanodollars is None: raise ModelCallUnavailable("model_call_completion_unavailable") @@ -1114,6 +1233,7 @@ def _completed_result(row: CheckerModelCallAttempt, *, replayed: bool) -> Comple @staticmethod def _failed_result(row: CheckerModelCallAttempt, *, replayed: bool) -> KnownFailedModelCall: + """Project a known failed call only from complete billed terminal custody.""" usage = _usage(row) if row.terminal_at is None or usage is None or row.settled_nanodollars is None: raise ModelCallUnavailable("model_call_completion_unavailable") @@ -1132,4 +1252,4 @@ def _failed_result(row: CheckerModelCallAttempt, *, replayed: bool) -> KnownFail ) -__all__ = ("ModelCallService",) +__all__ = ("ModelCallService", "model_recovery_candidates") diff --git a/backend/app/modules/checkers/router.py b/backend/app/modules/checkers/router.py index 001fe6b3b..aed3dca4a 100644 --- a/backend/app/modules/checkers/router.py +++ b/backend/app/modules/checkers/router.py @@ -29,8 +29,10 @@ MODEL_ADMIN_IDEMPOTENCY_PARAMETER, CheckerModelAdminCommands, get_checker_model_admin_commands, + get_checker_model_recovery_commands, require_model_admin_human, require_model_admin_key, + require_model_recovery_human, ) from app.api.deps.history import HistoryReadOperation, get_history_reads @@ -126,13 +128,11 @@ async def configure_project_model_budget( status_code=status.HTTP_201_CREATED, dependencies=[ Depends(enforce_admin_mutation_rate_limit), - Depends(require_model_admin_human), - Depends(require_model_admin_key), + Depends(require_model_recovery_human), ], responses={code: {"model": ApiErrorResponse} for code in (404, 409, 422, 503)}, openapi_extra={ "x-workstream-action-id": "project.model_call.recovery_child.authorize", - "parameters": [MODEL_ADMIN_IDEMPOTENCY_PARAMETER], }, ) async def prepare_project_model_recovery_child( @@ -140,7 +140,7 @@ async def prepare_project_model_recovery_child( parent_attempt_id: UUID, payload: ModelRecoveryChildRequest, commands: Annotated[ - CheckerModelAdminCommands, Depends(get_checker_model_admin_commands) + CheckerModelAdminCommands, Depends(get_checker_model_recovery_commands) ], ) -> PreparedModelCallAttempt: """Create or replay a child only for one exact known terminal paid effect.""" diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 8adb9cca3..014a04f45 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -115,6 +115,33 @@ "backend/app/modules/authorization/artifact_operator_authorization.py", "backend/app/modules/authorization/domain/artifact_operator.py", }) +CHECKER_MODEL_RUNTIME_TARGETS = frozenset({ + "backend/app/adapters/checkers/model_execution.py", + "backend/app/adapters/checkers/model_provider.py", + "backend/app/adapters/checkers/model_proxy_transport.py", + "backend/app/adapters/checkers/model_runtime.py", + "backend/app/api/deps/checker_models.py", + "backend/app/interfaces/checker_model_provider.py", + "backend/app/interfaces/checker_model_proxy.py", + "backend/app/modules/artifacts/api/checker_output_authority.py", + "backend/app/modules/artifacts/checker_model_evidence.py", + "backend/app/modules/authorization/checker_model_authorization.py", + "backend/app/modules/authorization/checker_output_authorization.py", + "backend/app/modules/authorization/domain/checker_models.py", + "backend/app/modules/authorization/domain/checker_outputs.py", + "backend/app/modules/authorization/domain/prepared_checker_runtime.py", + "backend/app/modules/checkers/api/model_calls.py", + "backend/app/modules/checkers/api/model_grants.py", + "backend/app/modules/checkers/api/model_profiles.py", + "backend/app/modules/checkers/model_calls/custody.py", + "backend/app/modules/checkers/model_calls/grants.py", + "backend/app/modules/checkers/model_calls/models.py", + "backend/app/modules/checkers/model_calls/profiles.py", + "backend/app/modules/checkers/model_calls/proxy.py", + "backend/app/modules/checkers/model_calls/repository.py", + "backend/app/modules/checkers/model_calls/service.py", + "backend/app/workers/checker_model_proxy.py", +}) PILOT_READ_1_TARGETS = frozenset({ "backend/app/modules/tasks/submission_attempts.py", }) @@ -1019,6 +1046,7 @@ def _validate_additive_partition_transition( | PILOT_READ_1_TARGETS | AWS_ARTIFACT_ACTIVATION_TARGETS | AUTH_ART_OPERATOR_TARGETS + | CHECKER_MODEL_RUNTIME_TARGETS | CHECKER_IMAGE_PUBLISH_TARGETS | PILOT_READ_2_TARGETS | PILOT_READ_3_TARGETS diff --git a/backend/tests/authorization/checker_models/test_contracts.py b/backend/tests/authorization/checker_models/test_contracts.py index 23347a77e..9c762e832 100644 --- a/backend/tests/authorization/checker_models/test_contracts.py +++ b/backend/tests/authorization/checker_models/test_contracts.py @@ -26,6 +26,7 @@ checker_output_prepare_matches, checker_output_prepare_resource, checker_output_stored_resource, + parse_checker_output_prepare, ) from app.modules.authorization.prepared import PreparedAuthorizationHandle from app.modules.authorization.runtime import ( @@ -136,6 +137,25 @@ def test_grant_resource_binds_opaque_digests_and_exact_slot() -> None: assert resource.facts.token_hash == sha("opaque token") +@pytest.mark.parametrize("action", [WRITE, BIND]) +def test_output_prepare_accepts_its_json_packet_without_coercing_integers(action) -> None: + """Prepared AUTH receives JSON values, including nested UUIDs and timestamps.""" + current = execution() + prepared = checker_output_prepare_resource(action, selector(current, paid_slot(current))) + packet = prepared.model_dump(mode="json") + assert parse_checker_output_prepare(action, packet, RuntimeError) == prepared + + packet["selector"]["paid_slot"]["submission_version"] = "1" + with pytest.raises(RuntimeError) as rejected: + parse_checker_output_prepare(action, packet, RuntimeError) + assert isinstance(rejected.value.__cause__, ValidationError) + assert any( + error["loc"] == ("selector", "paid_slot", "submission_version") + and error["type"] == "int_type" + for error in rejected.value.__cause__.errors() + ) + + def test_output_final_facts_cannot_substitute_selector_or_unverified_binding() -> None: current = execution() slot = paid_slot(current) diff --git a/backend/tests/checker_output_admission_helpers.py b/backend/tests/checker_output_admission_helpers.py index 87dd24ce3..085345a7c 100644 --- a/backend/tests/checker_output_admission_helpers.py +++ b/backend/tests/checker_output_admission_helpers.py @@ -14,6 +14,9 @@ ArtifactAuthorityDeniedError, CheckerOutputArtifactAdmissionRequest, ) +from app.modules.artifacts.api.checker_output_authority import ( + CheckerOutputCommitmentAuthorityFacts, +) from app.modules.artifacts.models import ( ArtifactAdmissionCharge, ArtifactAdmissionScope, @@ -332,7 +335,7 @@ def __init__(self, session, actor_id: UUID, identity_link_id: UUID) -> None: self._identity_link_id = identity_link_id self.calls = 0 - async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: + async def consume(self, facts: CheckerOutputCommitmentAuthorityFacts) -> None: self.calls += 1 proof = await ActorService(self._session).lock_admission_proof( self._actor_id, diff --git a/backend/tests/checker_output_custody_helpers.py b/backend/tests/checker_output_custody_helpers.py index 1d5277d03..65f10c389 100644 --- a/backend/tests/checker_output_custody_helpers.py +++ b/backend/tests/checker_output_custody_helpers.py @@ -6,12 +6,14 @@ import asyncio import hashlib +import json from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any -from uuid import UUID +from uuid import UUID, uuid4 from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine @@ -23,6 +25,7 @@ CheckerOutputArtifactResult, CheckerOutputBindingRequest, CheckerOutputBindingResult, + CheckerOutputSlot, ) from app.interfaces.artifacts import ( ArtifactByteRange, @@ -34,20 +37,33 @@ from app.modules.artifacts.checker_output_bindings import ( CheckerOutputBindingService, ) -from app.modules.artifacts.checker_output_custody import CheckerOutputStoredFacts +from app.modules.artifacts.api.checker_output_authority import ( + CheckerOutputCommitmentAuthorityFacts, + CheckerOutputStoredFacts, +) from app.modules.artifacts.checker_outputs import CheckerArtifactOutputService +from app.modules.artifacts.checker_model_evidence import CheckerModelEvidence from app.modules.artifacts.models import ArtifactBinding from app.modules.artifacts.preparation import ( ArtifactPreparationService, ArtifactScratchManager, ) -from app.modules.artifacts.schemas import CheckerOutputArtifactAdmissionRequest from app.modules.artifacts.service import artifact_storage_namespace_spec from app.modules.checkers.api.output_custody import ( CheckerOutputReservation, CheckerOutputSelector, CheckerOutputUnavailable, ) +from app.modules.checkers.api.model_calls import ( + ModelCallEvidence, + ModelCallExecutionFacts, + ModelPaidSlotKey, + ProjectModelProfile, +) +from app.modules.checkers.execution_repository import stored_request +from app.modules.checkers.models import CheckerRun, ExternalCheckerRegistryEntryRecord +from app.modules.checkers.model_calls.service import ModelCallService +from app.adapters.auth import checker_model_service_authorization from app.modules.authorization.checker_output_authorization import ( PreparedCheckerOutputBindingAuthorization, PreparedCheckerOutputWriteAuthorization, @@ -57,7 +73,12 @@ artifact_admission_limit_settings, artifact_preparation_limits, ) -from tests.checker_output_admission_helpers import make_checker_output_reservation +from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( + BoundaryPorts, +) +from tests.checkers_model_calls.test_public_api import profile_spec +from tests.checkers.execution.support import provision_post_submit_service +from tests.checkers.external.support import post_registry_spec from tests.projects.unified_policy_fixtures import create_standalone_unified_policy from tests.test_artifact_admission import ( _AllowArtifactAuthority, @@ -88,7 +109,7 @@ class ControlledReservationState: revoked: bool = False preflights: list[CheckerOutputSelector] = field(default_factory=list) prepares: list[CheckerOutputSelector] = field(default_factory=list) - admissions: list[CheckerOutputArtifactAdmissionRequest] = field(default_factory=list) + admissions: list[CheckerOutputCommitmentAuthorityFacts] = field(default_factory=list) recoveries: list[tuple[CheckerOutputReservation, CheckerOutputStoredFacts]] = field( default_factory=list ) @@ -148,11 +169,11 @@ async def prepare(self, selector: CheckerOutputSelector) -> None: self._state.prepares.append(selector) await self._authority.prepare(selector) - async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: + async def consume(self, facts: CheckerOutputCommitmentAuthorityFacts) -> None: self._state.require_authority() - assert request.reservation == self._state.reservation - await self._authority.consume(request) - self._state.admissions.append(request) + assert facts.reservation == self._state.reservation + await self._authority.consume(facts) + self._state.admissions.append(facts) async def consume_existing( self, @@ -239,6 +260,11 @@ def open( async def head(self, provider_object_ref: str, **_kwargs): return await self.wrapped.head(provider_object_ref) + def reset_observations(self) -> None: + """Start the response-custody observation window after request evidence setup.""" + self.puts = 0 + self.opens = 0 + @dataclass(slots=True) class OutputCustodyHarness: @@ -257,6 +283,8 @@ class OutputCustodyHarness: actor_id: UUID identity_link_id: UUID policy_bundle: Any + model_profile: Any + model_ports: BoundaryPorts @property def selector(self) -> CheckerOutputSelector: @@ -319,7 +347,10 @@ async def binding_rows(self) -> list[tuple[str, str, str, str, str]]: ArtifactBinding.put_attempt_id, ArtifactBinding.verification_receipt_id, ) - .where(ArtifactBinding.resource_type == "checker_model_call") + .where( + ArtifactBinding.resource_type == "checker_model_call", + ArtifactBinding.logical_role == "model_response", + ) .order_by(ArtifactBinding.id) ) return [tuple(row) for row in rows] @@ -331,6 +362,7 @@ async def seed_reservation( content_sha256: str | None = None, ) -> CheckerOutputReservation: """Create a second coherent lineage without changing the active test reservation.""" + provider_counts = self.store.puts, self.store.opens async with self.factory() as session: _, _, checker_run_id = await seed_checker_output_relationships( session, @@ -340,11 +372,274 @@ async def seed_reservation( reservation = await make_checker_output_reservation( session, checker_run_id, + factory=self.factory, + preparation=self.preparation, + store=self.store, + namespace=self.namespace, + settings=self.settings, + model_profile=self.model_profile, + model_ports=self.model_ports, maximum_bytes=maximum_bytes, content_sha256=content_sha256, ) await session.commit() - return reservation + self.store.puts, self.store.opens = provider_counts + return reservation + + +async def _model_execution(session, checker_run_id: str): + run = await session.get(CheckerRun, checker_run_id) + assert run is not None + request = stored_request(run) + return run, request + + +async def _prepare_model_attempt( + session: AsyncSession, + checker_run_id: str, + request_sha256: str, + *, + model_profile, + model_ports: BoundaryPorts, +): + run, request = await _model_execution(session, checker_run_id) + registry = await session.scalar(select(ExternalCheckerRegistryEntryRecord).limit(1)) + assert registry is not None + slot = ModelPaidSlotKey( + project_id=request.project_id, + assignment_id=request.assignment_id, + submission_id=request.submission_id, + submission_version=request.submission_version, + requirement_id="model_evidence", + registry_entry_digest=registry.entry_digest, + configuration_sha256="sha256:" + hashlib.sha256(b"controlled-configuration").hexdigest(), + profile_id=model_profile.profile_id, + profile_version=model_profile.spec.profile_version, + profile_digest=model_profile.profile_digest, + ) + execution = ModelCallExecutionFacts( + project_id=request.project_id, + task_id=request.task_id, + assignment_id=request.assignment_id, + submission_id=request.submission_id, + submission_version=request.submission_version, + evaluation_request_id=request.evaluation_request_id, + evaluation_generation=request.evaluation_generation, + checker_run_id=UUID(run.id), + worker_lease_id=new_record_id(), + worker_lease_generation=1, + worker_lease_expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + authority = await checker_model_service_authorization(session, execution.evaluation_request_id) + owner = ModelCallService( + session, + authority=authority, + current_execution=model_ports, + evidence=CheckerModelEvidence(session), + ) + attempt = await owner.prepare_attempt( + execution, + slot, + registry_entry_id=UUID(str(registry.id)), + request_sha256=request_sha256, + ) + return execution, attempt, owner + + +async def _store_model_request_evidence( + *, + factory, + preparation, + store, + namespace, + settings, + reservation: CheckerOutputReservation, + request_bytes: bytes, +) -> ModelCallEvidence: + """Store and bind exact model-request evidence through production ART custody.""" + state = ControlledReservationState(reservation) + service = CheckerArtifactOutputService( + sessions=factory, + reservations=lambda value: ControlledReservationPort(value, state), + authority=lambda value: ControlledWriteAuthority(value, state), + internal_authority=lambda value: _AllowArtifactAuthority(), + verification_authority=lambda value: _AllowArtifactAuthority(), + preparation=preparation, + store=store, + namespace=namespace, + settings=settings, + ) + stored = await service.store( + CheckerOutputArtifactRequest(reservation.selector, byte_stream(request_bytes)) + ) + assert stored.content_id is not None and stored.verification_receipt_id is not None + async with factory() as session, session.begin(): + bound = await CheckerOutputBindingService( + session, + reservations=ControlledReservationPort(session, state), + authority=ControlledBindingAuthority(session, state), + namespace_fingerprint=namespace.namespace_fingerprint, + ).bind_checker_output( + CheckerOutputBindingRequest( + reservation.selector, + stored.put_attempt_id, + stored.verification_receipt_id, + ) + ) + return ModelCallEvidence( + binding_id=bound.binding_id, + content_id=bound.content_id, + put_attempt_id=bound.put_attempt_id, + verification_receipt_id=bound.verification_receipt_id, + content_sha256=reservation.selector.content_sha256, + byte_count=len(request_bytes), + ) + + +async def make_checker_output_reservation( + session: AsyncSession, + checker_run_id: str, + *, + factory, + preparation, + store, + namespace, + settings, + model_profile, + model_ports: BoundaryPorts, + maximum_bytes: int, + content_sha256: str | None = None, +) -> CheckerOutputReservation: + """Create a dispatch-reserved call through real request ART custody.""" + request_bytes = b'{"prompt":"controlled output-custody evidence"}' + request_sha256 = "sha256:" + hashlib.sha256(request_bytes).hexdigest() + execution, attempt, _ = await _prepare_model_attempt( + session, + checker_run_id, + request_sha256, + model_profile=model_profile, + model_ports=model_ports, + ) + await session.commit() + request_reservation = CheckerOutputReservation( + selector=CheckerOutputSelector( + paid_slot=attempt.slot_key, + task_id=execution.task_id, + model_call_slot_id=attempt.slot_id, + model_call_attempt_id=attempt.attempt_id, + slot_key="model_request", + content_sha256=request_sha256, + execution=execution, + ), + slot=CheckerOutputSlot( + key="model_request", + media_type="application/json", + maximum_bytes=model_profile.spec.maximum_request_bytes, + ), + ) + evidence = await _store_model_request_evidence( + factory=factory, + preparation=preparation, + store=store, + namespace=namespace, + settings=settings, + reservation=request_reservation, + request_bytes=request_bytes, + ) + async with factory() as dispatch_session, dispatch_session.begin(): + authority = await checker_model_service_authorization( + dispatch_session, execution.evaluation_request_id + ) + await ModelCallService( + dispatch_session, + authority=authority, + current_execution=model_ports, + evidence=CheckerModelEvidence(dispatch_session), + ).reserve_dispatch(execution, attempt, evidence) + digest = ( + content_sha256 + or "sha256:" + hashlib.sha256(f"{attempt.attempt_id}:model_response".encode()).hexdigest() + ) + return CheckerOutputReservation( + selector=CheckerOutputSelector( + paid_slot=attempt.slot_key, + task_id=execution.task_id, + model_call_slot_id=attempt.slot_id, + model_call_attempt_id=attempt.attempt_id, + slot_key="model_response", + content_sha256=digest, + ), + slot=CheckerOutputSlot( + key="model_response", + media_type="application/json", + maximum_bytes=maximum_bytes, + ), + ) + + +async def _provision_model_policy(factory, namespace, admin_access): + """Publish one ordinary policy plus its exact trusted model controls.""" + await admin_access.signed.grant(admin_access.admin, admin_access.target, role="operator") + policy_bundle = await create_standalone_unified_policy( + factory, + namespace, + post_submit_registry_spec=post_registry_spec(), + ) + async with factory() as session: + registry = await session.scalar(select(ExternalCheckerRegistryEntryRecord).limit(1)) + assert registry is not None + project_id = policy_bundle[0]["project"] + spec = profile_spec(project_id).model_copy( + update={ + "registry_entry_id": UUID(str(registry.id)), + "registry_entry_digest": registry.entry_digest, + "maximum_request_bytes": 1024, + } + ) + response = await admin_access.signed.client.post( + f"/api/v1/operations/projects/{project_id}/model-profiles", + headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, + json=spec.model_dump(mode="json"), + ) + assert response.status_code == 201, response.text + budget = await admin_access.signed.client.post( + f"/api/v1/operations/projects/{project_id}/model-budget-configurations", + headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, + json={ + "project_id": str(project_id), + "configuration_version": 1, + "lifetime_limit_nanodollars": 10_000_000_000, + }, + ) + assert budget.status_code == 201, budget.text + await provision_post_submit_service(factory, ServiceIdentity.CHECKER_POST_SUBMIT) + return policy_bundle, ProjectModelProfile.model_validate_json(json.dumps(response.json())) + + +async def _ensure_output_actor(factory): + """Reuse the canonical output service actor, creating it only when absent.""" + async with factory() as session: + existing = ( + await session.execute( + select(ActorProfile.id, ActorIdentityLink.id) + .join(ActorIdentityLink, ActorIdentityLink.actor_profile_id == ActorProfile.id) + .where( + ActorProfile.service_identity == ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value + ) + ) + ).one_or_none() + if existing is not None: + await session.rollback() + return tuple(UUID(value) for value in existing) + actor_id, identity_link_id = new_record_id(), new_record_id() + _add_checker_output_actor( + session, + actor_id, + identity_link_id, + subject=f"checker-output-{actor_id}", + ) + await session.commit() + return actor_id, identity_link_id @asynccontextmanager @@ -352,6 +647,7 @@ async def output_custody_harness( tmp_path: Path, database_url: str, *, + admin_access, provider: str = "local", maximum_bytes: int = 1024, ) -> AsyncIterator[OutputCustodyHarness]: @@ -386,41 +682,32 @@ async def output_custody_harness( ) preparation = ArtifactPreparationService(manager) try: - policy_bundle = await create_standalone_unified_policy(factory, namespace) + policy_bundle, model_profile = await _provision_model_policy( + factory, namespace, admin_access + ) async with factory() as session: _, _, checker_run_id = await seed_checker_output_relationships( session, namespace, policy_bundle=policy_bundle, ) - existing = ( - await session.execute( - select(ActorProfile.id, ActorIdentityLink.id) - .join(ActorIdentityLink, ActorIdentityLink.actor_profile_id == ActorProfile.id) - .where( - ActorProfile.service_identity - == ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value - ) - ) - ).one_or_none() - if existing is None: - actor_id, identity_link_id = new_record_id(), new_record_id() - _add_checker_output_actor( - session, - actor_id, - identity_link_id, - subject=f"checker-output-{actor_id}", - ) - await session.commit() - else: - actor_id, identity_link_id = (UUID(value) for value in existing) - await session.rollback() + actor_id, identity_link_id = await _ensure_output_actor(factory) + model_ports = BoundaryPorts() + async with factory() as session: reservation = await make_checker_output_reservation( session, checker_run_id, + factory=factory, + preparation=preparation, + store=store, + namespace=namespace, + settings=settings, + model_profile=model_profile, + model_ports=model_ports, maximum_bytes=maximum_bytes, ) await session.commit() + store.reset_observations() state = ControlledReservationState(reservation) service = CheckerArtifactOutputService( sessions=factory, @@ -447,6 +734,8 @@ async def output_custody_harness( actor_id=actor_id, identity_link_id=identity_link_id, policy_bundle=policy_bundle, + model_profile=model_profile, + model_ports=model_ports, ) finally: manager.close() diff --git a/backend/tests/checkers/execution/test_checker_retry_authority.py b/backend/tests/checkers/execution/test_checker_retry_authority.py index 8860acf0e..0f28487b8 100644 --- a/backend/tests/checkers/execution/test_checker_retry_authority.py +++ b/backend/tests/checkers/execution/test_checker_retry_authority.py @@ -283,10 +283,11 @@ def capture_projection(_conn, _cursor, _statement, _parameters, context, _many): item = body["items"][0] assert set(item) == { "source", "recovery_state", "created_at", "completed_at", - "retry_receipt_id", "successor", + "retry_receipt_id", "successor", "model_recovery_candidates", } assert item["recovery_state"] == "eligible" assert item["retry_receipt_id"] is None and item["successor"] is None + assert item["model_recovery_candidates"] == [] assert item["source"] == { "project_id": str(h.request.project_id), "task_id": str(h.request.task_id), diff --git a/backend/tests/checkers/external/test_harnessy_proof_checker.py b/backend/tests/checkers/external/test_harnessy_proof_checker.py index d278b837a..45b39d63c 100644 --- a/backend/tests/checkers/external/test_harnessy_proof_checker.py +++ b/backend/tests/checkers/external/test_harnessy_proof_checker.py @@ -156,6 +156,7 @@ def test_published_registry_bodies_are_backend_valid_for_both_phases() -> None: def test_published_service_paths_match_the_compose_overlay() -> None: """The service config and the overlay must name the same daemon and socket paths.""" + base = yaml.safe_load((CHECKER_ROOT.parents[1] / "docker-compose.yml").read_text()) overlay = yaml.safe_load( (CHECKER_ROOT.parents[1] / "docker-compose.agent-loop.yml").read_text() ) @@ -193,13 +194,23 @@ def test_published_service_paths_match_the_compose_overlay() -> None: assert model_config["model_proxy_socket"] == "/run/ws-model-proxy/model-proxy.sock" assert model_config["model_relay_root"] == relay_root assert len(f"{relay_root}/.model-{'0' * 16}/model.sock".encode()) < 108 - assert "checker_model_proxy:/run/ws-model-proxy" in overlay["services"]["backend"]["volumes"] - for name in ("backend", "worker", "beat"): + base_proxy = base["services"]["model-proxy"] + overlay_proxy = overlay["services"]["model-proxy"] + assert base_proxy["profiles"] == ["model-proxy"] + assert base_proxy["command"] == ["python", "-m", "app.workers.checker_model_proxy"] + assert base_proxy["stop_grace_period"] == "125m" + assert "extends" not in overlay_proxy + assert "profiles" not in overlay_proxy + assert overlay_proxy["environment"]["WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET"] == ( + "/run/ws-model-proxy/model-proxy.sock" + ) + assert "checker_model_proxy:/run/ws-model-proxy" in overlay_proxy["volumes"] + for name in ("backend", "worker", "beat", "model-proxy"): assert all( "/var/run/docker.sock" not in str(mount) - for mount in overlay["services"][name]["volumes"] + for mount in overlay["services"].get(name, {}).get("volumes", ()) ) - for name in ("worker", "beat"): + for name in ("backend", "worker", "beat"): assert all( "checker_model_proxy" not in str(mount) for mount in overlay["services"][name]["volumes"] diff --git a/backend/tests/checkers/model_provider/test_openai_responses.py b/backend/tests/checkers/model_provider/test_openai_responses.py index 2c1a989c6..c2b0e7b48 100644 --- a/backend/tests/checkers/model_provider/test_openai_responses.py +++ b/backend/tests/checkers/model_provider/test_openai_responses.py @@ -12,8 +12,8 @@ from app.adapters.checkers.model_provider import ( OpenAIResponsesCheckerModelProvider, OpenAIResponsesSettings, - create_checker_model_provider, ) +from app.adapters.checkers import create_checker_model_provider from app.interfaces.checker_model_provider import ( CHECKER_MODEL_CAPABILITY_KEY, MAX_CHECKER_MODEL_OUTPUT_BYTES, diff --git a/backend/tests/checkers_model_calls/test_composed_execution.py b/backend/tests/checkers_model_calls/test_composed_execution.py index 766282018..b6b8808c1 100644 --- a/backend/tests/checkers_model_calls/test_composed_execution.py +++ b/backend/tests/checkers_model_calls/test_composed_execution.py @@ -1,15 +1,24 @@ """Real approval, AUTH, PostgreSQL and ART with an offline provider and image boundary.""" from contextlib import asynccontextmanager +import asyncio +import os from uuid import UUID, uuid4 +import pytest from sqlalchemy import select, func from app.adapters.artifacts import checker_output_binding, checker_output_storage from app.adapters.auth import checker_model_service_authorization -from app.adapters.checkers import model_call_service, model_invocation_grants, model_profile_reader +from app.adapters.checkers import ( + evaluation_coordinator, + model_call_service, + model_invocation_grants, + model_profile_reader, +) from app.adapters.checkers.model_execution import ModelAwareExternalExecution from app.interfaces.checker_model_provider import ( + CheckerModelCreateFailure, CheckerModelCreateOutcome, CheckerModelCreateResult, CheckerModelObservation, @@ -18,15 +27,16 @@ CheckerModelUsage, ) from app.interfaces.checker_model_proxy import ModelProxyRequest -from app.interfaces.external_services import ExternalServiceAdapterIdentity -from app.interfaces.project_agents import CapabilityParameter +from app.interfaces.external_services import ExternalServiceAdapterIdentity, ExternalServiceUnavailableError +from app.interfaces.project_agents import CapabilityParameter, CheckSpecification from app.modules.actors.api import ServiceIdentity +from app.modules.artifacts.preparation import HARD_MAXIMUM_ARTIFACT_BYTES from app.modules.checkers.api.external import ExternalCheckerSchema, external_checker_json_hash from app.modules.checkers.model_calls.models import ( CheckerModelCallAttempt, CheckerModelBudgetAccount, ) -from app.modules.checkers.model_calls.proxy import CheckerModelProxy +from app.adapters.checkers.model_proxy import CheckerModelProxy from tests.checkers.external.support import post_registry_spec from tests.checkers.execution.support import ( ControlledSelectedExecution, @@ -36,6 +46,34 @@ ) from tests.checkers_model_calls.test_public_api import profile_spec from tests.post_submit_materialization_helpers import material_fixture +from tests.test_checker_materialization import _limits +from tests.projects.guide_compilation.helpers import valid_specification + + +def model_review_specification(): + """Declare model review explicitly before the real policy compiler binds it.""" + specification = valid_specification().model_dump(mode="json") + specification.update( + evaluation_method="model_evaluation", + execution_mode="background", + maximum_duration_seconds=120, + stage_rationale="Content review runs after immutable submission creation.", + runtime_dependencies=[{ + "dependency_id": "model_runtime", + "kind": "model_service", + "provisioning": "model_proxy", + "purpose": "Evaluate submitted evidence through the budgeted model service.", + "unavailable_code": "implementation_unavailable", + }], + infrastructure_failures=[ + *specification["infrastructure_failures"], + { + "code": "implementation_unavailable", + "condition": "The budgeted model service is unavailable.", + }, + ], + ) + return CheckSpecification.model_validate(specification) def model_registry_spec(): @@ -52,6 +90,7 @@ def model_registry_spec(): } return spec.model_copy( update={ + "resources": spec.resources.model_copy(update={"deadline_ms": 120_000}), "configuration_schema": ExternalCheckerSchema( schema_id="acme.model_review.configuration", schema_version="v1", @@ -62,11 +101,33 @@ def model_registry_spec(): ) +@pytest.mark.parametrize( + ("storage_provider", "lose_first_image_reply", "concurrent_callback", "terminal_mode"), + [ + pytest.param("local", False, False, None, id="completed"), + pytest.param("local", True, False, None, id="retry-retains-paid-effect"), + pytest.param("minio", True, False, None, id="minio-retry-retains-paid-effect"), + pytest.param("local", False, True, None, id="concurrent-callback-single-charge"), + pytest.param("local", False, False, "no_effect", id="operator-recovers-no-effect"), + pytest.param("minio", False, False, "failed", id="operator-recovers-settled-failure"), + ], +) async def test_public_profile_to_private_model_call_and_completed_checker( tmp_path, isolated_database_env, admin_access, + monkeypatch, + storage_provider, + lose_first_image_reply, + concurrent_callback, + terminal_mode, ): + if storage_provider == "minio": + from tests import test_s3_artifact_store + + monkeypatch.setattr( + test_s3_artifact_store, "MINIO_BUCKET", os.environ["WORKSTREAM_TEST_MINIO_BUCKET"] + ) await admin_access.signed.grant(admin_access.admin, admin_access.target, role="operator") profiles = [] @@ -107,24 +168,42 @@ async def publish(_factory, project_id, registry): async with material_fixture( tmp_path, isolated_database_env, + provider=storage_provider, post_submit_registry_spec=model_registry_spec(), post_submit_binding_setup=publish, + post_submit_specification=model_review_specification(), + # This composed process holds ZIP material while also retaining model + # evidence; the ordinary two-slot material-only fixture is insufficient. + scratch_limits=_limits( + aggregate_reserved_bytes=4 * HARD_MAXIMUM_ARTIFACT_BYTES, + maximum_files=4, + maximum_concurrency=4, + total_deadline_seconds=120.0, + reservation_ttl_seconds=150.0, + ), ) as h: await provision_post_submit_service(h.factory, ServiceIdentity.ARTIFACT_CHECKER_OUTPUT) creates = [] + lost_replies = [] class Provider: async def create(self, request): creates.append(request.prompt) - reference = CheckerModelResponseReference("resp_composed") + if terminal_mode == "no_effect" and len(creates) == 1: + return CheckerModelCreateResult( + CheckerModelCreateOutcome.NO_EFFECT, + failure=CheckerModelCreateFailure.INPUT_COUNT_UNAVAILABLE, + ) + failed = terminal_mode == "failed" and len(creates) == 1 + reference = CheckerModelResponseReference(f"resp_composed_{len(creates)}") return CheckerModelCreateResult( CheckerModelCreateOutcome.ACCEPTED, known_response=reference, observation=CheckerModelObservation( reference, profiles[0]["spec"]["model_snapshot"], - CheckerModelProviderStatus.COMPLETED, - '{"verdict":"passed"}', + CheckerModelProviderStatus.FAILED if failed else CheckerModelProviderStatus.COMPLETED, + None if failed else '{"verdict":"passed"}', CheckerModelUsage(20, 5, 10, 2, 30), ), ) @@ -144,7 +223,7 @@ async def provider(_): profiles=model_profile_reader, outputs=checker_output_storage( sessions=h.factory, - store=h.store, + store=h.store.wrapped, namespace=h.namespace, preparation=h.preparation, settings=h.settings, @@ -154,19 +233,38 @@ async def provider(_): ) class Image(ControlledSelectedExecution): - identity = ExternalServiceAdapterIdentity("external_checker_execution", "rust_unix") + identity = ExternalServiceAdapterIdentity("external_checker_execution", "unix_socket") + + def __init__(self): + super().__init__() + self.invocations = 0 async def execute(self, request, grant, *, model_grant=None): + self.invocations += 1 assert model_grant is not None - reply = await proxy.handle( - ModelProxyRequest( - model_grant.grant_id, - request.request_digest, - "Evaluate the verified work", - ) + callback = ModelProxyRequest( + model_grant.grant_id, + request.request_digest, + "Evaluate the verified work", ) + if concurrent_callback: + replies = await asyncio.gather(proxy.handle(callback), proxy.handle(callback)) + assert sorted(reply.outcome for reply in replies) == ["completed", "unavailable"] + reply = next(reply for reply in replies if reply.outcome == "completed") + else: + reply = await proxy.handle(callback) + if terminal_mode is not None and self.invocations == 1: + assert reply.outcome == "unavailable", reply.outcome + assert reply.failure_code == ( + "model_provider_no_effect" if terminal_mode == "no_effect" + else "model_provider_failed" + ) + raise ExternalServiceUnavailableError(self.identity) assert reply.outcome == "completed", reply.failure_code assert reply.output == '{"verdict":"passed"}' + if lose_first_image_reply and self.invocations == 1: + lost_replies.append(reply.receipt_digest) + raise ExternalServiceUnavailableError(self.identity) return await super().execute(request, grant) await reserve(h) @@ -177,19 +275,71 @@ async def execute(self, request, grant, *, model_grant=None): sessions=h.factory, ), ) - result = await executor.evaluate_post_submission(h.request) + current_request = h.request + result = await executor.evaluate_post_submission(current_request) + if lose_first_image_reply or terminal_mode is not None: + assert result.outcome == "infrastructure_failed" + if lose_first_image_reply: + assert len(lost_replies) == 1, result.infrastructure_failure_code + if terminal_mode is not None: + sources = await admin_access.signed.client.get( + f"/api/v1/operations/projects/{h.request.project_id}/checker-retry-sources", + headers=admin_access.target.headers, + ) + assert sources.status_code == 200, sources.text + candidates = sources.json()["items"][0]["model_recovery_candidates"] + assert len(candidates) == 1 + candidate = candidates[0] + parent = candidate.pop("parent_attempt_id") + recovery_path = ( + f"/api/v1/operations/projects/{h.request.project_id}/model-call-attempts/" + f"{parent}/recovery-children" + ) + recovery_headers = admin_access.target.headers + child = await admin_access.signed.client.post( + recovery_path, headers=recovery_headers, json=candidate, + ) + assert child.status_code == 201, child.text + replay = await admin_access.signed.client.post( + recovery_path, headers=recovery_headers, json=candidate, + ) + assert replay.status_code == 201, replay.text + assert replay.json()["attempt_id"] == child.json()["attempt_id"] + assert len(creates) == 1 # Recovery authorization itself cannot dispatch. + retry = await admin_access.signed.client.post( + f"/api/v1/operations/projects/{h.request.project_id}/checker-runs/" + f"{h.created.evaluation_attempt_id}/retry", + headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Recover lost image response without repeating the paid model call"}, + ) + assert retry.status_code == 201, retry.text + async with h.factory() as session, session.begin(): + reserved = await evaluation_coordinator(session).read_reserved_evaluation( + project_id=h.request.project_id, + task_id=h.request.task_id, + submission_id=h.request.submission_id, + request_id=UUID(retry.json()["successor"]["request_id"]), + ) + current_request = reserved.request + assert current_request.evaluation_generation == 2 + result = await executor.evaluate_post_submission(current_request) assert result.outcome == "completed" assert len(result.execution.external_members) == 1 - assert await executor.evaluate_post_submission(h.request) == result - assert len(creates) == 1 + assert await executor.evaluate_post_submission(current_request) == result + assert len(creates) == (2 if terminal_mode else 1) async with h.factory() as session: assert ( - await session.scalar(select(func.count()).select_from(CheckerModelCallAttempt)) == 1 + await session.scalar(select(func.count()).select_from(CheckerModelCallAttempt)) + == (2 if terminal_mode else 1) + ) + attempt = await session.scalar( + select(CheckerModelCallAttempt).where(CheckerModelCallAttempt.state == "completed") ) - attempt = await session.scalar(select(CheckerModelCallAttempt)) account = await session.get(CheckerModelBudgetAccount, str(h.request.project_id)) assert attempt.state == "completed" assert account.lifetime_reserved_nanodollars == 0 - assert account.lifetime_settled_nanodollars == 15 * 11 + 5 * 2 + 10 * 29 + assert account.lifetime_settled_nanodollars == ( + (15 * 11 + 5 * 2 + 10 * 29) * (2 if terminal_mode == "failed" else 1) + ) assert UUID(attempt.response_binding_id).version == 7 assert UUID(attempt.request_binding_id).version == 7 diff --git a/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py index 781d74a45..56dd073a0 100644 --- a/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py @@ -42,6 +42,7 @@ ) from app.modules.checkers.model_calls.models import CheckerModelBudgetAccount from app.modules.checkers.model_calls.profiles import ModelProfileReader +from app.modules.checkers.model_calls.repository import ModelCallRepository from app.modules.checkers.model_calls.service import ModelCallService @@ -92,12 +93,13 @@ def isolated(statement: str) -> str: "project_id uuid not null)", "create table submissions(id uuid primary key, task_id uuid not null, " "version integer not null, unique(id,task_id,version))", - "create table external_checker_registry_entries(id uuid primary key)", + "create table external_checker_registry_entries(id uuid primary key," + "entry_digest text not null,phase text not null)", "create table checker_runs(id uuid primary key)", "create table actor_profiles(id uuid primary key)", "create table audit_events(id uuid primary key, event_domain text, event_type text, " "actor_ref_kind text, actor_id text, action_id text, permission_id text, " - "denial_code text, after_facts jsonb)", + "denial_code text, after_facts json)", "create table artifact_bindings(id uuid primary key)", "create table artifact_contents(id uuid primary key)", "create table artifact_put_attempts(id uuid primary key)", @@ -124,8 +126,11 @@ def isolated(statement: str) -> str: {"id": submission_id, "task": task_id}, ) await connection.execute( - text("insert into external_checker_registry_entries(id) values (:id)"), - {"id": registry_entry_id}, + text( + "insert into external_checker_registry_entries(id,entry_digest,phase) " + "values (:id,:digest,'post_submit')" + ), + {"id": registry_entry_id, "digest": registry_entry_digest}, ) try: yield DatabaseFacts( @@ -211,11 +216,11 @@ async def authorize_observe(self, effect, provider_response_id, observation_dige }, ) - async def authorize_child(self, execution, parent_attempt_id, terminal_digest): + async def authorize_child(self, project_id, parent_attempt_id, terminal_digest): return self._decision( "child", { - "execution": execution.model_dump(mode="json"), + "project_id": str(project_id), "parent_attempt_id": str(parent_attempt_id), "terminal_digest": terminal_digest, }, @@ -239,6 +244,10 @@ async def require_current(self, facts, slot): self.current_checks += 1 self.events.append("current") + async def require_recovery_current(self, slot, parent_attempt_id): + self.current_checks += 1 + self.events.append("recovery_current") + async def require_request(self, attempt, evidence): self.request_checks += 1 @@ -267,7 +276,7 @@ async def _authority( "insert into audit_events(id,event_domain,event_type,actor_ref_kind,actor_id," "action_id,permission_id,denial_code,after_facts) values " "(:id,'authority','SensitiveAuthorizationAllowed','actor_profile',:actor," - ":action,:permission,null,cast(:after as jsonb))" + ":action,:permission,null,cast(:after as json))" ), { "id": evidence.evidence_id, @@ -344,9 +353,9 @@ async def authorize_observe(self, effect, provider_response_id, observation_dige evidence, action="checker.model_call.observe", permission="checker.model_call.observe" ) - async def authorize_child(self, execution, parent_attempt_id, terminal_digest): + async def authorize_child(self, project_id, parent_attempt_id, terminal_digest): evidence = await self._ports.authorize_child( - execution, parent_attempt_id, terminal_digest + project_id, parent_attempt_id, terminal_digest ) return await self._authority( evidence, @@ -367,6 +376,9 @@ async def authorize_complete(self, effect, response_evidence, terminal_digest): async def require_current(self, facts, slot): await self._ports.require_current(facts, slot) + async def require_recovery_current(self, slot, parent_attempt_id): + await self._ports.require_recovery_current(slot, parent_attempt_id) + async def _artifact(self, evidence): for table_name, value in ( ("artifact_bindings", evidence.binding_id), @@ -404,6 +416,7 @@ async def provision( *, limit: int, replay: bool = False, + profile_changes: dict | None = None, ): profile_spec = ProjectModelProfileSpec( project_id=facts.project_id, @@ -429,6 +442,8 @@ async def provision( output_nanodollars_per_token=29, ), ) + if profile_changes: + profile_spec = profile_spec.model_copy(update=profile_changes) profile_facts = ModelProfileProvisioningFacts( operation_id=uuid4(), actor_profile_id=new_record_id(), @@ -480,6 +495,105 @@ async def test_profile_publication_rejects_substituted_authority_digest( ) +async def test_profile_publication_requires_exact_post_submit_registry( + model_database: DatabaseFacts, +) -> None: + with pytest.raises(ModelCallConflict, match="model_profile_registry_conflict"): + await provision( + model_database, + BoundaryPorts(), + limit=10_000, + profile_changes={"registry_entry_digest": sha("substituted-registry")}, + ) + + pre_entry_id = new_record_id() + pre_digest = sha("pre-registry") + async with model_database.engine.begin() as connection: + await connection.execute( + text( + "insert into external_checker_registry_entries(id,entry_digest,phase) " + "values (:id,:digest,'pre_submit')" + ), + {"id": pre_entry_id, "digest": pre_digest}, + ) + with pytest.raises(ModelCallConflict, match="model_profile_registry_conflict"): + await provision( + model_database, + BoundaryPorts(), + limit=10_000, + profile_changes={ + "registry_entry_id": pre_entry_id, + "registry_entry_digest": pre_digest, + }, + ) + + +async def test_database_profile_guard_rechecks_registry_digest( + model_database: DatabaseFacts, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def substituted_registry(*_args, **_kwargs): + return True + + monkeypatch.setattr( + ModelCallRepository, + "is_exact_post_submit_registry_entry", + substituted_registry, + ) + with pytest.raises(DBAPIError, match="profile registry binding"): + await provision( + model_database, + BoundaryPorts(), + limit=10_000, + profile_changes={"registry_entry_digest": sha("substituted-registry")}, + ) + + +async def test_database_guards_recompute_profile_and_budget_digests( + model_database: DatabaseFacts, +) -> None: + await provision(model_database, BoundaryPorts(), limit=10_000) + async with AsyncSession(model_database.engine) as session: + with pytest.raises(DBAPIError, match="profile digest is invalid"): + async with session.begin(): + await session.execute( + text( + "insert into checker_model_profiles select " + ":id,project_id,2,:digest,registry_entry_id,registry_entry_digest," + "provider,api,model_snapshot,generation_settings,maximum_request_bytes," + "maximum_output_bytes,maximum_response_bytes,maximum_input_tokens," + "maximum_output_tokens,deadline_ms,call_slots,pricing_policy_id," + "pricing_policy_version,pricing_policy_digest,uncached_input_rate," + "cached_input_rate,output_rate,:operation,actor_profile_id,:authority," + "clock_timestamp() from checker_model_profiles where profile_version=1" + ), + { + "id": new_record_id(), + "digest": sha("noncanonical-profile"), + "operation": uuid4(), + "authority": new_record_id(), + }, + ) + + async with AsyncSession(model_database.engine) as session: + with pytest.raises(DBAPIError, match="configuration digest is invalid"): + async with session.begin(): + await session.execute( + text( + "insert into checker_model_budget_configurations select " + ":id,project_id,2,:digest,lifetime_limit_nanodollars,:operation," + "actor_profile_id,:authority,clock_timestamp() " + "from checker_model_budget_configurations where configuration_version=1" + ), + { + "id": new_record_id(), + "digest": sha("noncanonical-budget"), + "operation": uuid4(), + "authority": new_record_id(), + }, + ) + + def execution(facts: DatabaseFacts) -> ModelCallExecutionFacts: return ModelCallExecutionFacts( project_id=facts.project_id, @@ -629,14 +743,13 @@ async def test_fresh_execution_authority_precedes_currentness_and_owner_locks( ports.events.clear() with pytest.raises(ModelCallConflict, match="not_retryable"): await owner.prepare_recovery_child( - run, paid_slot, registry_entry_id=model_database.registry_entry_id, parent_attempt_id=prepared.attempt_id, terminal_digest=sha("ordered-uncertain"), request_sha256=prepared.request_sha256, ) - assert ports.events == ["child", "current"] + assert ports.events == ["child", "recovery_current"] async def test_reservation_rollback_and_completion_settle_exactly_once( @@ -732,7 +845,6 @@ async def test_uncertain_holds_budget_and_only_proven_no_effect_allows_child( assert observed.state == "uncertain" with pytest.raises(ModelCallConflict, match="not_retryable"): await owner.prepare_recovery_child( - run, paid_slot, registry_entry_id=model_database.registry_entry_id, parent_attempt_id=prepared.attempt_id, @@ -756,7 +868,6 @@ async def test_uncertain_holds_budget_and_only_proven_no_effect_allows_child( owner = service(session, ports) await owner.mark_known_no_effect(dispatched2.effect, proof) child = await owner.prepare_recovery_child( - run2, paid_slot2, registry_entry_id=model_database.registry_entry_id, parent_attempt_id=prepared2.attempt_id, @@ -765,6 +876,14 @@ async def test_uncertain_holds_budget_and_only_proven_no_effect_allows_child( ) assert child.attempt_number == 2 assert child.state == "prepared" + with pytest.raises(ModelCallConflict, match="parent_not_retryable"): + await owner.prepare_recovery_child( + paid_slot2, + registry_entry_id=model_database.registry_entry_id, + parent_attempt_id=prepared2.attempt_id, + terminal_digest=sha("substituted-terminal"), + request_sha256=prepared2.request_sha256, + ) account = await session.get(CheckerModelBudgetAccount, str(model_database.project_id)) assert account.lifetime_reserved_nanodollars == reservation @@ -867,7 +986,6 @@ async def test_known_failed_settles_once_and_allows_authorized_child( ) assert replay.replayed child = await owner.prepare_recovery_child( - run, paid_slot, registry_entry_id=model_database.registry_entry_id, parent_attempt_id=prepared.attempt_id, diff --git a/backend/tests/checkers_model_calls/test_model_runtime.py b/backend/tests/checkers_model_calls/test_model_runtime.py index 96c9e64b7..61820cca2 100644 --- a/backend/tests/checkers_model_calls/test_model_runtime.py +++ b/backend/tests/checkers_model_calls/test_model_runtime.py @@ -1,6 +1,9 @@ """Configured proxy startup and provider ownership boundaries.""" +import asyncio +from contextlib import asynccontextmanager from pathlib import Path +import signal from types import SimpleNamespace import pytest @@ -9,6 +12,7 @@ from app.adapters.checkers import model_runtime from app.core.config import Settings from app.modules.checkers.api.model_calls import ModelCallUnavailable +from app.workers import checker_model_proxy from tests.artifact_store_helpers import artifact_admission_limit_settings from tests.checkers_model_calls.test_proxy_orchestration import Harness @@ -80,3 +84,52 @@ async def test_missing_provider_key_stops_before_reserving_budget(monkeypatch): with pytest.raises(ModelCallUnavailable): async with model_runtime.configured_model_provider(h.profile): pass + + +async def test_singleton_worker_drains_before_engine_disposal(monkeypatch): + """SIGTERM stops admission and awaits the owned runtime before DB disposal.""" + calls = [] + settings = SimpleNamespace(checker_model_proxy_socket=Path("/private/model.sock")) + sessions = object() + real_loop = asyncio.get_running_loop() + + class SignalLoop: + def add_signal_handler(self, signum, callback): + calls.append(("add_signal", signum)) + if signum == signal.SIGTERM: + real_loop.call_soon(callback) + + def remove_signal_handler(self, signum): + calls.append(("remove_signal", signum)) + return True + + @asynccontextmanager + async def runtime(received_settings, received_sessions): + assert received_settings is settings + assert received_sessions is sessions + calls.append("runtime_started") + try: + yield + finally: + calls.append("runtime_drained") + + async def dispose(): + calls.append("engine_disposed") + + monkeypatch.setattr(checker_model_proxy, "get_settings", lambda: settings) + monkeypatch.setattr(checker_model_proxy, "get_session_factory", lambda: sessions) + monkeypatch.setattr(checker_model_proxy, "configured_model_proxy_runtime", runtime) + monkeypatch.setattr(checker_model_proxy, "dispose_engine", dispose) + monkeypatch.setattr(checker_model_proxy.asyncio, "get_running_loop", SignalLoop) + + await checker_model_proxy.serve() + + assert calls == [ + ("add_signal", signal.SIGINT), + ("add_signal", signal.SIGTERM), + "runtime_started", + "runtime_drained", + ("remove_signal", signal.SIGINT), + ("remove_signal", signal.SIGTERM), + "engine_disposed", + ] diff --git a/backend/tests/checkers_model_calls/test_proxy_orchestration.py b/backend/tests/checkers_model_calls/test_proxy_orchestration.py index 4a80850f6..908a34ad2 100644 --- a/backend/tests/checkers_model_calls/test_proxy_orchestration.py +++ b/backend/tests/checkers_model_calls/test_proxy_orchestration.py @@ -33,7 +33,7 @@ ModelPaidSlotKey, ProjectModelProfile, ) -from app.modules.checkers.model_calls.proxy import CheckerModelProxy +from app.adapters.checkers.model_proxy import CheckerModelProxy from tests.checkers_model_calls.test_model_call_contract import profile_spec @@ -274,6 +274,51 @@ async def test_completed_effect_is_reused_across_proxy_restart(): assert json.loads(h.blobs["model_request"])["prompt"] == "Evaluate this work" +async def test_invalid_prompt_cannot_consume_grant_store_bytes_or_reserve_spend(): + h = Harness() + reply = await h.invoke("") + assert reply.failure_code == "model_request_invalid" + assert h.state == "prepared" + assert h.request_sha is None + assert h.events == [] + assert h.blobs == {} + assert h.create_count == 0 + + +async def test_crash_before_provider_identity_commit_retains_one_paid_effect(): + h = Harness() + + async def crash_before_reference(*_args, **_kwargs): + raise RuntimeError("controlled process loss before provider identity commit") + + h.mark_uncertain = crash_before_reference + with pytest.raises(RuntimeError, match="controlled process loss"): + await h.invoke() + assert h.state == "dispatching" + assert h.provider_id is None + assert h.create_count == 1 + assert (await h.invoke()).outcome == "unavailable" + assert (h.create_count, h.recover_count, h.settle_count) == (1, 0, 0) + + +async def test_lease_loss_during_cached_art_read_denies_output_but_preserves_effect(): + h = Harness() + original = await h.invoke() + read_response = h.read_response + + async def lose_lease_while_reading(*args): + data = await read_response(*args) + h.stale = True + return data + + h.read_response = lose_lease_while_reading + assert (await h.invoke()).outcome == "unavailable" + assert (h.create_count, h.settle_count) == (1, 1) + h.read_response, h.stale = read_response, False + assert await h.invoke() == original + assert (h.create_count, h.settle_count) == (1, 1) + + @pytest.mark.parametrize("known_id", [True, False]) async def test_lost_create_response_never_creates_again(known_id): h = Harness() diff --git a/backend/tests/checkers_model_calls/test_public_api.py b/backend/tests/checkers_model_calls/test_public_api.py index 29d004d12..05fa17108 100644 --- a/backend/tests/checkers_model_calls/test_public_api.py +++ b/backend/tests/checkers_model_calls/test_public_api.py @@ -1,9 +1,12 @@ """Public Operator contract for checker model profile, budget and recovery controls.""" from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 import pytest +from sqlalchemy import text +from app.db import session as db_session from app.api.deps.checker_models import CheckerModelAdminCommands from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id @@ -18,6 +21,12 @@ ProjectModelBudgetConfigurationSpec, ProjectModelProfileSpec, ) +from tests.authorization.admin_access.support import create_project +from tests.checkers.external.support import post_registry_spec + + +PROFILE_PATH = "/api/v1/operations/projects/{project_id}/model-profiles" +BUDGET_PATH = "/api/v1/operations/projects/{project_id}/model-budget-configurations" def sha(label: str) -> str: @@ -75,8 +84,11 @@ def test_openapi_exposes_only_exact_operator_model_mutations() -> None: "$ref": f"#/components/schemas/{schema_name}" } keys = [item for item in operation["parameters"] if item["name"] == "Idempotency-Key"] - assert len(keys) == 1 - assert keys[0]["required"] is True + if schema_name == "ModelRecoveryChildRequest": + assert keys == [] + else: + assert len(keys) == 1 + assert keys[0]["required"] is True @pytest.mark.asyncio @@ -92,8 +104,8 @@ async def provision_budget(self, facts): self.calls.append(("budget", facts)) return facts - async def prepare_recovery_child(self, execution, slot, **values): - self.calls.append(("child", execution, slot, values)) + async def prepare_recovery_child(self, slot, **values): + self.calls.append(("child", slot, values)) return values project_id = new_record_id() @@ -136,7 +148,6 @@ async def prepare_recovery_child(self, execution, slot, **values): profile_digest=sha("profile"), ) request = ModelRecoveryChildRequest( - execution=execution, slot=slot, registry_entry_id=profile.registry_entry_id, terminal_digest=sha("terminal"), @@ -156,7 +167,6 @@ async def prepare_recovery_child(self, execution, slot, **values): assert budget_facts.spec == budget assert service.calls[2] == ( "child", - execution, slot, { "registry_entry_id": profile.registry_entry_id, @@ -168,3 +178,227 @@ async def prepare_recovery_child(self, execution, slot, **values): with pytest.raises(ModelCallConflict, match="project_conflict"): await commands.provision_profile(new_record_id(), profile) + + +async def _model_control_counts() -> dict[str, int]: + async with db_session.get_session_factory()() as session: + rows = {} + for name in ( + "checker_model_profiles", + "checker_model_budget_configurations", + "checker_model_budget_accounts", + "checker_model_call_attempts", + ): + rows[name] = int(await session.scalar(text(f"select count(*) from {name}")) or 0) + events = ( + await session.execute( + text( + "select action_id,after_facts->>'allowed' as allowed,count(*) as count " + "from audit_events where action_id in " + "('project.model_profile.provision','project.model_budget.configure'," + "'project.model_call.recovery_child.authorize') " + "group by action_id,after_facts->>'allowed'" + ) + ) + ).mappings() + for event in events: + rows[f"{event['action_id']}:{event['allowed']}"] = int(event["count"]) + return rows + + +@pytest.mark.asyncio +async def test_signed_operator_profile_budget_replay_and_fresh_denial( + admin_access, +) -> None: + """Replay retains one row, while revoked authority still denies the same key.""" + grant_id = await admin_access.signed.grant( + admin_access.admin, admin_access.target, role="operator" + ) + registry_key = uuid4() + registered = await admin_access.signed.client.post( + "/api/v1/operations/external-checker-registry", + headers=admin_access.target.headers | {"Idempotency-Key": str(registry_key)}, + json=post_registry_spec().model_dump(mode="json"), + ) + assert registered.status_code == 201, registered.text + entry = registered.json() + project_id = await create_project("Model control replay") + + profile_key = uuid4() + profile = profile_spec(project_id).model_copy( + update={ + "registry_entry_id": UUID(entry["registry_entry_id"]), + "registry_entry_digest": entry["entry_digest"], + } + ) + profile_headers = admin_access.target.headers | {"Idempotency-Key": str(profile_key)} + profile_path = PROFILE_PATH.format(project_id=project_id) + malformed_profile = profile.model_dump(mode="json") + malformed_profile["registry_entry_id"] = "not-a-uuid" + malformed = await admin_access.signed.client.post( + profile_path, + headers=admin_access.target.headers, + json=malformed_profile, + ) + assert malformed.status_code == 422, malformed.text + first_profile = await admin_access.signed.client.post( + profile_path, + headers=profile_headers, + json=profile.model_dump(mode="json"), + ) + assert first_profile.status_code == 201, first_profile.text + replayed_profile = await admin_access.signed.client.post( + profile_path, + headers=profile_headers, + json=profile.model_dump(mode="json"), + ) + assert replayed_profile.status_code == 201, replayed_profile.text + assert replayed_profile.json() == first_profile.json() + assert first_profile.json()["operation_id"] == str(profile_key) + substituted_profile = profile.model_copy( + update={"registry_entry_digest": sha("substituted-registry")} + ) + substituted = await admin_access.signed.client.post( + profile_path, + headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, + json=substituted_profile.model_dump(mode="json"), + ) + assert substituted.status_code == 409, substituted.text + assert substituted.json()["error"]["code"] == "checker_model_control_conflict" + + budget_key = uuid4() + budget = ProjectModelBudgetConfigurationSpec( + project_id=project_id, + configuration_version=1, + lifetime_limit_nanodollars=10_000_000_000, + ) + budget_headers = admin_access.target.headers | {"Idempotency-Key": str(budget_key)} + budget_path = BUDGET_PATH.format(project_id=project_id) + malformed_budget = budget.model_dump(mode="json") + malformed_budget["project_id"] = "not-a-uuid" + malformed = await admin_access.signed.client.post( + budget_path, + headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, + json=malformed_budget, + ) + assert malformed.status_code == 422, malformed.text + first_budget = await admin_access.signed.client.post( + budget_path, + headers=budget_headers, + json=budget.model_dump(mode="json"), + ) + assert first_budget.status_code == 201, first_budget.text + replayed_budget = await admin_access.signed.client.post( + budget_path, + headers=budget_headers, + json=budget.model_dump(mode="json"), + ) + assert replayed_budget.status_code == 201, replayed_budget.text + assert replayed_budget.json() == first_budget.json() + assert first_budget.json()["operation_id"] == str(budget_key) + async with db_session.get_session_factory()() as session: + targets = ( + ( + await session.execute( + text( + "select action_id,project_id,resource_type,resource_id," + "target_ref_kind,target_ref_id from audit_events " + "where id in (:profile,:budget) order by action_id" + ), + { + "profile": first_profile.json()["authorization_evidence_id"], + "budget": first_budget.json()["authorization_evidence_id"], + }, + ) + ) + .mappings() + .all() + ) + assert len(targets) == 2 + assert all( + ( + str(row["project_id"]), + row["resource_type"], + row["resource_id"], + row["target_ref_kind"], + row["target_ref_id"], + ) + == (str(project_id), "project", str(project_id), "project", str(project_id)) + for row in targets + ) + + revoked = await admin_access.signed.revoke(admin_access.admin, grant_id) + assert revoked.status_code == 200, revoked.text + for path, headers, payload in ( + (profile_path, profile_headers, profile.model_dump(mode="json")), + (budget_path, budget_headers, budget.model_dump(mode="json")), + ): + denied = await admin_access.signed.client.post( + path, + headers=headers, + json=payload, + ) + assert denied.status_code == 404, denied.text + assert denied.json()["error"]["code"] == "checker_model_control_not_found" + + selector = first_profile.json() + execution = ModelCallExecutionFacts( + project_id=project_id, + task_id=new_record_id(), + assignment_id=new_record_id(), + submission_id=new_record_id(), + submission_version=1, + evaluation_request_id=new_record_id(), + evaluation_generation=1, + checker_run_id=new_record_id(), + worker_lease_id=new_record_id(), + worker_lease_generation=1, + worker_lease_expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + slot = ModelPaidSlotKey( + project_id=project_id, + assignment_id=execution.assignment_id, + submission_id=execution.submission_id, + submission_version=1, + requirement_id="model_review", + registry_entry_digest=entry["entry_digest"], + configuration_sha256=sha("configuration"), + profile_id=UUID(selector["profile_id"]), + profile_version=selector["spec"]["profile_version"], + profile_digest=selector["profile_digest"], + ) + recovery = ModelRecoveryChildRequest( + slot=slot, + registry_entry_id=UUID(entry["registry_entry_id"]), + terminal_digest=sha("terminal"), + request_sha256=sha("request"), + ) + malformed_recovery = recovery.model_dump(mode="json") + malformed_recovery["slot"]["profile_id"] = "not-a-uuid" + malformed = await admin_access.signed.client.post( + "/api/v1/operations/projects/" + f"{project_id}/model-call-attempts/{new_record_id()}/recovery-children", + headers=admin_access.target.headers, + json=malformed_recovery, + ) + assert malformed.status_code == 422, malformed.text + denied_recovery = await admin_access.signed.client.post( + "/api/v1/operations/projects/" + f"{project_id}/model-call-attempts/{new_record_id()}/recovery-children", + headers=admin_access.target.headers, + json=recovery.model_dump(mode="json"), + ) + assert denied_recovery.status_code == 404, denied_recovery.text + assert denied_recovery.json()["error"]["code"] == "checker_model_control_not_found" + + assert await _model_control_counts() == { + "checker_model_profiles": 1, + "checker_model_budget_configurations": 1, + "checker_model_budget_accounts": 1, + "checker_model_call_attempts": 0, + "project.model_profile.provision:true": 2, + "project.model_profile.provision:false": 1, + "project.model_budget.configure:true": 2, + "project.model_budget.configure:false": 1, + "project.model_call.recovery_child.authorize:false": 1, + } diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b1f3a9e3f..8b7751c82 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,7 +23,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "c06129a0f05f8062678f7d76016b2ec75cb457c088d54be07c7ad73924e976ee" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "70478073f22b57301b1b8d817ac13722105b77203c3a57384d2e35628e4e4260" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/post_submit_materialization_helpers.py b/backend/tests/post_submit_materialization_helpers.py index 5a94319c5..f3e154634 100644 --- a/backend/tests/post_submit_materialization_helpers.py +++ b/backend/tests/post_submit_materialization_helpers.py @@ -249,13 +249,13 @@ async def _material_fixture( pre_submit_registry_spec=None, post_submit_registry_spec=None, post_submit_binding_setup=None, + post_submit_specification=None, post_scratch_root=None, ): engine = create_async_engine(database_url) factory = async_sessionmaker(engine, expire_on_commit=False) settings, bootstrap, namespace, store, scratch, manager = _material_storage( - tmp_path, provider, storage_settings, scratch_limits, post_scratch_root - ) + tmp_path, provider, storage_settings, scratch_limits, post_scratch_root) try: plan, policy = await approved_selected_policy_fixture( factory, @@ -267,15 +267,9 @@ async def _material_fixture( pre_submit_registry_spec=pre_submit_registry_spec, post_submit_registry_spec=post_submit_registry_spec, post_submit_binding_setup=post_submit_binding_setup, + post_submit_specification=post_submit_specification, ) - ( - context, - task_id, - data, - admission_id, - creation_request, - created, - ) = await _create_material_submission( + context, task_id, data, admission_id, creation_request, created = await _create_material_submission( factory, engine, plan, diff --git a/backend/tests/projects/guide_activation/source_fixtures.py b/backend/tests/projects/guide_activation/source_fixtures.py index a2383caa3..e13f95817 100644 --- a/backend/tests/projects/guide_activation/source_fixtures.py +++ b/backend/tests/projects/guide_activation/source_fixtures.py @@ -89,6 +89,7 @@ def _external_compilation_outcome( refs, *, post_submit_parameters=(), + post_submit_specification=None, ): if not registry_entries: return outcome @@ -127,7 +128,7 @@ def _external_compilation_outcome( disposition="supported_post_submit", evidence_refs=refs, classification_rationale="Evaluate the submitted work in the background.", - check_specification=CheckSpecification.model_validate({ + check_specification=post_submit_specification or CheckSpecification.model_validate({ **valid_specification().model_dump(mode="json"), "execution_mode": "background", "stage_rationale": "The selected registry entry evaluates immutable submitted evidence.", @@ -162,6 +163,7 @@ async def create_compiled_guide( pre_submit_registry_entry=None, post_submit_registry_entry=None, post_submit_parameters=(), + post_submit_specification=None, compilation_outcome=None, ): """Create declarations with real AUTH, then execute/project/finalize scripted findings.""" @@ -221,6 +223,7 @@ async def create_compiled_guide( post_submit_registry_entry, refs, post_submit_parameters=post_submit_parameters, + post_submit_specification=post_submit_specification, ) url = factory.kw["bind"].url.render_as_string(hide_password=False) finalization = await compilation_and_projections( @@ -244,6 +247,7 @@ async def source_case( pre_submit_registry_spec=None, post_submit_registry_spec=None, post_submit_binding_setup: PostSubmitBindingSetup | None = None, + post_submit_specification=None, compilation_outcome=None, ): """An authorized draft Project plus a genuinely created complete guide source.""" @@ -311,6 +315,7 @@ async def source_case( pre_submit_registry_entry=pre_registry_entry, post_submit_registry_entry=post_registry_entry, post_submit_parameters=post_submit_parameters, + post_submit_specification=post_submit_specification, compilation_outcome=compilation_outcome, ) yield values, factory, finalization, actor, grant diff --git a/backend/tests/projects/guide_compilation/proposals/test_postgresql.py b/backend/tests/projects/guide_compilation/proposals/test_postgresql.py index a0de2bf96..9eee54f16 100644 --- a/backend/tests/projects/guide_compilation/proposals/test_postgresql.py +++ b/backend/tests/projects/guide_compilation/proposals/test_postgresql.py @@ -60,6 +60,7 @@ async def approve(factory, command, actor, grant, payload): from app.adapters.checkers import ( external_checker_capability_projection, external_checker_registry_reader, + model_profile_reader, ) return await GuideProposalService( @@ -71,6 +72,7 @@ async def approve(factory, command, actor, grant, payload): grant, ), external_checker_registry_reader(session), + model_profile_reader(session), ).approve( payload, actor=actor, diff --git a/backend/tests/projects/unified_policy_fixtures.py b/backend/tests/projects/unified_policy_fixtures.py index cafa26b59..9faeb9b6c 100644 --- a/backend/tests/projects/unified_policy_fixtures.py +++ b/backend/tests/projects/unified_policy_fixtures.py @@ -170,6 +170,7 @@ async def create_standalone_unified_policy( pre_submit_registry_spec=None, post_submit_registry_spec=None, post_submit_binding_setup=None, + post_submit_specification=None, ): """Arrange complete activated context before a downstream artifact transaction.""" from app.modules.projects.models import PreSubmitCheckerPolicy @@ -190,6 +191,7 @@ async def create_standalone_unified_policy( pre_submit_registry_spec=pre_submit_registry_spec, post_submit_registry_spec=post_submit_registry_spec, post_submit_binding_setup=post_submit_binding_setup, + post_submit_specification=post_submit_specification, ) as (values, _, command, actor, grant): await seed_selected_review_revision_inputs( sessions, command, actor, human_review_required=human_review_required, diff --git a/backend/tests/selected_policy_fixtures.py b/backend/tests/selected_policy_fixtures.py index b4cbe3773..236692038 100644 --- a/backend/tests/selected_policy_fixtures.py +++ b/backend/tests/selected_policy_fixtures.py @@ -17,6 +17,7 @@ async def approved_selected_policy_fixture( pre_submit_registry_spec=None, post_submit_registry_spec=None, post_submit_binding_setup=None, + post_submit_specification=None, ): """Build an activated guide backed only by immutable external policy custody.""" from app.interfaces.project_agents import SubmissionArtifactPolicyProposal @@ -40,6 +41,7 @@ async def approved_selected_policy_fixture( pre_submit_registry_spec=pre_submit_registry_spec, post_submit_registry_spec=post_submit_registry_spec, post_submit_binding_setup=post_submit_binding_setup, + post_submit_specification=post_submit_specification, ) async with factory() as session: guide = await session.get(ProjectGuide, str(values["guide"])) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index a3abca526..60949bb7a 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -339,6 +339,7 @@ def test_repeated_upgrade_head_preserves_current_database( "0055_delivery_reconciliation", "0056_manager_authored_runtime", "0057_roles_anywhere_custody", + "0058_compensation_unit_create", ]) def test_preceding_head_upgrades_to_current_without_recreation( isolated_database_env: str, migration_lock, installed_revision: str, diff --git a/backend/tests/test_artifact_architecture.py b/backend/tests/test_artifact_architecture.py index 0bdaad38e..4e10dc02a 100644 --- a/backend/tests/test_artifact_architecture.py +++ b/backend/tests/test_artifact_architecture.py @@ -33,6 +33,7 @@ ) COMPOSITION_ROOT = APP_ROOT / "adapters" / "artifacts" / "__init__.py" CHECKER_COMPOSITION_ROOT = APP_ROOT / "adapters" / "checkers" / "__init__.py" +MODEL_RUNTIME_COMPOSITION = APP_ROOT / "adapters" / "checkers" / "model_runtime.py" AGENT_COMPOSITION_ROOT = APP_ROOT / "adapters/project_agents/__init__.py" OBSERVABILITY_COMPOSITION_ROOT = APP_ROOT / "adapters" / "observability.py" AGENT_ADAPTER_MODULE = "app.adapters.project_agents.openai_agent_sdk" @@ -89,6 +90,7 @@ "app.adapters.artifacts.local", "app.adapters.artifacts.s3_compatible", "app.adapters.checkers.external_service", + "app.adapters.checkers.model_provider", } CONCRETE_OBSERVABILITY_MODULES = { "opentelemetry.exporter.otlp.proto.http.metric_exporter", @@ -234,7 +236,12 @@ def test_only_artifact_custody_services_own_provider_execution() -> None: node.name: node for node in tree.body if isinstance(node, ast.ClassDef) - and node.name in {"ArtifactStorageOrchestrator", "ScopedGuideDocumentGrant", "PostSubmissionMaterializer"} + and node.name in { + "ArtifactStorageOrchestrator", + "CheckerArtifactOutputService", + "ScopedGuideDocumentGrant", + "PostSubmissionMaterializer", + } } for node in ast.walk(tree): if ( @@ -255,7 +262,13 @@ def test_only_artifact_custody_services_own_provider_execution() -> None: None, ) if owner is None or ( - owner.name in {"ScopedGuideDocumentGrant", "PostSubmissionMaterializer"} and node.func.attr != "open" + owner.name + in { + "CheckerArtifactOutputService", + "ScopedGuideDocumentGrant", + "PostSubmissionMaterializer", + } + and node.func.attr != "open" ): violations.append(f"{path.relative_to(BACKEND_ROOT)} calls {node.func.attr}") assert violations == [] @@ -339,6 +352,7 @@ def _assert_concrete_adapter_composition( "LocalStorageAdapter", "S3CompatibleArtifactStore", "UnixSocketExternalCheckerAdapter", + "OpenAIResponsesCheckerModelProvider", } ) agent_calls.extend(path for name in calls if name == "OpenAIAgentSdkProjectGuideRuntime") @@ -353,12 +367,15 @@ def _assert_concrete_adapter_composition( AGENT_COMPOSITION_ROOT, OBSERVABILITY_COMPOSITION_ROOT, } - assert len(factory_calls) == 4 - assert adapter_calls == [COMPOSITION_ROOT, S3_ADAPTER_MODULE, CHECKER_COMPOSITION_ROOT] + assert len(factory_calls) == 5 + assert adapter_calls == [ + COMPOSITION_ROOT, S3_ADAPTER_MODULE, CHECKER_COMPOSITION_ROOT, CHECKER_COMPOSITION_ROOT, + ] assert set(concrete_imports) == { COMPOSITION_ROOT, CHECKER_COMPOSITION_ROOT, AWS_LIVE_PROOF_MODULE, + MODEL_RUNTIME_COMPOSITION, } assert agent_imports == [AGENT_COMPOSITION_ROOT] assert agent_calls == [AGENT_COMPOSITION_ROOT] @@ -704,6 +721,7 @@ def _assert_checker_custody_consumers( expected = { "app.modules.checkers.api.output_custody": { "app/adapters/artifacts/__init__.py", + "app/modules/artifacts/api/checker_output_authority.py", "app/modules/artifacts/checker_output_bindings.py", "app/modules/artifacts/checker_output_custody.py", "app/modules/artifacts/checker_outputs.py", diff --git a/backend/tests/test_checker_output_custody.py b/backend/tests/test_checker_output_custody.py index 33a6cdb42..a778ae1e6 100644 --- a/backend/tests/test_checker_output_custody.py +++ b/backend/tests/test_checker_output_custody.py @@ -9,6 +9,16 @@ import pytest from sqlalchemy import func, select +from tests.authentication.fixtures import ( + auth_database_env as auth_database_env, + clear_settings_cache as clear_settings_cache, + rsa_signing_material as rsa_signing_material, +) +from tests.authorization.admin_access.fixtures import ( + admin_access as admin_access, + signed_access as signed_access, +) + from app.interfaces.artifacts import ArtifactLimitExceededError from app.modules.actors.api import ServiceIdentity from app.modules.artifacts.models import ( @@ -16,10 +26,10 @@ ArtifactOperationReceipt, ArtifactPutObservationReceipt, ArtifactPutAttempt, + ArtifactReplica, ArtifactVerificationJob, ArtifactVerificationReceipt, ) -from app.modules.artifacts.service import ArtifactAdmissionConflictError from app.modules.checkers.api.output_custody import ( CheckerOutputArtifactRequest, CheckerOutputBindingRequest, @@ -41,6 +51,7 @@ def content_sha256(value: bytes) -> str: async def test_real_store_verification_and_binding( tmp_path, isolated_database_env, + admin_access, provider, ) -> None: """Store, independently verify and bind one exact output through real providers.""" @@ -51,6 +62,7 @@ async def test_real_store_verification_and_binding( async with output_custody_harness( tmp_path, isolated_database_env, + admin_access=admin_access, provider=provider, ) as harness: result = await harness.service.store(harness.request(b"verified checker output")) @@ -89,6 +101,33 @@ async def test_real_store_verification_and_binding( str(result.put_attempt_id), str(result.verification_receipt_id), ) + attempt = await session.get(ArtifactPutAttempt, str(result.put_attempt_id)) + receipt = await session.scalar( + select(ArtifactOperationReceipt).where( + ArtifactOperationReceipt.put_attempt_id == str(result.put_attempt_id) + ) + ) + job = await session.scalar( + select(ArtifactVerificationJob).where( + ArtifactVerificationJob.originating_put_attempt_id == str(result.put_attempt_id) + ) + ) + assert attempt is not None and receipt is not None and job is not None + replica = await session.get(ArtifactReplica, job.replica_id) + assert replica is not None + assert ( + receipt.model_call_attempt_id, + receipt.logical_role, + attempt.checker_output_custody_sealed, + job.checker_output_custody_sealed, + replica.checker_output_custody_sealed, + ) == ( + str(harness.selector.model_call_attempt_id), + harness.selector.slot_key, + True, + True, + True, + ) assert harness.state.admissions assert harness.state.recoveries assert harness.state.bindings @@ -97,9 +136,12 @@ async def test_real_store_verification_and_binding( async def test_identical_store_replay_reuses_verified_custody_without_provider_work( tmp_path, isolated_database_env, + admin_access, ) -> None: """Replay exact bytes through the stable attempt without another put or verify.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: + async with output_custody_harness( + tmp_path, isolated_database_env, admin_access=admin_access + ) as harness: first = await harness.service.store(harness.request(b"stable replay output")) assert first.status == "verified" assert first.content_id is not None @@ -145,7 +187,8 @@ async def test_identical_store_replay_reuses_verified_custody_without_provider_w .select_from(ArtifactPutAttempt) .where( ArtifactPutAttempt.model_call_attempt_id - == str(harness.selector.model_call_attempt_id) + == str(harness.selector.model_call_attempt_id), + ArtifactPutAttempt.logical_role == harness.selector.slot_key, ) ) == 1 @@ -181,9 +224,12 @@ async def test_identical_store_replay_reuses_verified_custody_without_provider_w async def test_foreign_lineage_and_changed_bytes_fail_before_provider( tmp_path, isolated_database_env, + admin_access, ) -> None: """Reject coherent foreign ownership before bytes and immutable replay drift before put.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: + async with output_custody_harness( + tmp_path, isolated_database_env, admin_access=admin_access + ) as harness: original_reservation = harness.state.reservation foreign = await harness.seed_reservation(content_sha256=content_sha256(b"foreign control")) foreign_selector = selector_for(foreign) @@ -220,7 +266,7 @@ async def test_foreign_lineage_and_changed_bytes_fail_before_provider( assert harness.store.puts == 2 assert (await harness.manager.usage()).reservation_count == 0 - with pytest.raises(ArtifactAdmissionConflictError): + with pytest.raises(CheckerOutputUnavailable, match="authority_unavailable"): await harness.service.store( harness.request(b"changed output", selector=original_selector) ) @@ -231,9 +277,12 @@ async def test_foreign_lineage_and_changed_bytes_fail_before_provider( async def test_binding_rejects_mixed_stored_lineage_before_consumption_or_mutation( tmp_path, isolated_database_env, + admin_access, ) -> None: """Bind two valid controls, then reject every original/foreign custody mix.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: + async with output_custody_harness( + tmp_path, isolated_database_env, admin_access=admin_access + ) as harness: original_reservation = harness.state.reservation foreign_reservation = await harness.seed_reservation( content_sha256=content_sha256(b"foreign binding control") @@ -308,9 +357,12 @@ async def test_binding_rejects_mixed_stored_lineage_before_consumption_or_mutati async def test_binding_rollback_and_concurrent_replay_are_atomic( tmp_path, isolated_database_env, + admin_access, ) -> None: """Keep caller rollback empty and serialize concurrent binding to one row.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: + async with output_custody_harness( + tmp_path, isolated_database_env, admin_access=admin_access + ) as harness: stored = await harness.service.store(harness.request(b"binding output")) assert stored.verification_receipt_id is not None request = CheckerOutputBindingRequest( @@ -341,6 +393,7 @@ async def bind_once(): .where( ArtifactBinding.resource_type == "checker_model_call", ArtifactBinding.resource_id == str(harness.selector.model_call_attempt_id), + ArtifactBinding.logical_role == harness.selector.slot_key, ) ) assert count == 1 @@ -349,9 +402,12 @@ async def bind_once(): async def test_unknown_put_is_observed_recovered_and_bound_without_bytes( tmp_path, isolated_database_env, + admin_access, ) -> None: """Recover lost acknowledgement through typed observation evidence and bind it.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: + async with output_custody_harness( + tmp_path, isolated_database_env, admin_access=admin_access + ) as harness: harness.store.lose_put_acknowledgement = True unknown = await harness.service.store(harness.request(b"observed output")) assert unknown.status == "acknowledgement_unknown" @@ -398,9 +454,12 @@ async def test_unknown_put_is_observed_recovered_and_bound_without_bytes( async def test_authority_revocation_during_put_prevents_return_and_binding( tmp_path, isolated_database_env, + admin_access, ) -> None: """Recheck authority after provider I/O and deny publication after revocation.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: + async with output_custody_harness( + tmp_path, isolated_database_env, admin_access=admin_access + ) as harness: harness.store.put_entered = asyncio.Event() harness.store.put_release = asyncio.Event() operation = asyncio.create_task(harness.service.store(harness.request(b"revoked output"))) @@ -415,7 +474,8 @@ async def test_authority_revocation_during_put_prevents_return_and_binding( select(ArtifactPutAttempt) .where( ArtifactPutAttempt.model_call_attempt_id - == str(harness.selector.model_call_attempt_id) + == str(harness.selector.model_call_attempt_id), + ArtifactPutAttempt.logical_role == harness.selector.slot_key, ) .with_for_update(nowait=True) ) @@ -430,7 +490,8 @@ async def test_authority_revocation_during_put_prevents_return_and_binding( await session.scalars( select(ArtifactPutAttempt).where( ArtifactPutAttempt.model_call_attempt_id - == str(harness.selector.model_call_attempt_id) + == str(harness.selector.model_call_attempt_id), + ArtifactPutAttempt.logical_role == harness.selector.slot_key, ) ) ).one() @@ -459,6 +520,7 @@ async def test_authority_revocation_during_put_prevents_return_and_binding( .where( ArtifactBinding.resource_type == "checker_model_call", ArtifactBinding.resource_id == str(harness.selector.model_call_attempt_id), + ArtifactBinding.logical_role == harness.selector.slot_key, ) ) assert binding_count == 0 @@ -467,9 +529,12 @@ async def test_authority_revocation_during_put_prevents_return_and_binding( async def test_cancellation_during_provider_put_cleans_scratch_and_retains_uncertainty( tmp_path, isolated_database_env, + admin_access, ) -> None: """Preserve cancellation while retaining only the durable in-flight attempt.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: + async with output_custody_harness( + tmp_path, isolated_database_env, admin_access=admin_access + ) as harness: harness.store.put_entered = asyncio.Event() harness.store.put_release = asyncio.Event() operation = asyncio.create_task(harness.service.store(harness.request(b"cancelled output"))) @@ -500,7 +565,8 @@ async def test_cancellation_during_provider_put_cleans_scratch_and_retains_uncer attempt = await session.scalar( select(ArtifactPutAttempt).where( ArtifactPutAttempt.model_call_attempt_id - == str(harness.selector.model_call_attempt_id) + == str(harness.selector.model_call_attempt_id), + ArtifactPutAttempt.logical_role == harness.selector.slot_key, ) ) assert attempt is not None @@ -518,7 +584,8 @@ async def test_cancellation_during_provider_put_cleans_scratch_and_retains_uncer .select_from(ArtifactOperationReceipt) .where( ArtifactOperationReceipt.model_call_attempt_id - == str(harness.selector.model_call_attempt_id) + == str(harness.selector.model_call_attempt_id), + ArtifactOperationReceipt.logical_role == harness.selector.slot_key, ) ) == 0 @@ -546,6 +613,7 @@ async def test_cancellation_during_provider_put_cleans_scratch_and_retains_uncer .where( ArtifactBinding.resource_type == "checker_model_call", ArtifactBinding.resource_id == str(harness.selector.model_call_attempt_id), + ArtifactBinding.logical_role == harness.selector.slot_key, ) ) == 0 @@ -555,11 +623,13 @@ async def test_cancellation_during_provider_put_cleans_scratch_and_retains_uncer async def test_per_slot_cap_stops_source_and_cleans_scratch( tmp_path, isolated_database_env, + admin_access, ) -> None: """Apply the owner-issued cap before excess bytes reach durable intent or provider.""" async with output_custody_harness( tmp_path, isolated_database_env, + admin_access=admin_access, maximum_bytes=4, ) as harness: observed: list[bytes] = [] @@ -577,6 +647,10 @@ async def test_per_slot_cap_stops_source_and_cleans_scratch( attempts = await session.scalar( select(func.count()) .select_from(ArtifactPutAttempt) - .where(ArtifactPutAttempt.producer_request_type == "checker_output") + .where( + ArtifactPutAttempt.model_call_attempt_id + == str(harness.selector.model_call_attempt_id), + ArtifactPutAttempt.logical_role == harness.selector.slot_key, + ) ) assert attempts == 0 diff --git a/cli/internal/api/checker_models.go b/cli/internal/api/checker_models.go new file mode 100644 index 000000000..3e8165e63 --- /dev/null +++ b/cli/internal/api/checker_models.go @@ -0,0 +1,65 @@ +package api + +import ( + "encoding/json" + jsonv2 "encoding/json/v2" + "math/big" +) + +// CheckerModelPaidSlot identifies a retained effect across checker retries. +type CheckerModelPaidSlot struct { + ProjectID string `json:"project_id"` + AssignmentID string `json:"assignment_id"` + SubmissionID string `json:"submission_id"` + SubmissionVersion *big.Int `json:"submission_version"` + RequirementID string `json:"requirement_id"` + RegistryEntryDigest string `json:"registry_entry_digest"` + ConfigurationSHA256 string `json:"configuration_sha256"` + ProfileID string `json:"profile_id"` + ProfileVersion *big.Int `json:"profile_version"` + ProfileDigest string `json:"profile_digest"` + SlotIndex *big.Int `json:"slot_index"` +} + +// CheckerModelRecoveryCandidate is a repair candidate, never dispatch authority. +type CheckerModelRecoveryCandidate struct { + ParentAttemptID string `json:"parent_attempt_id"` + Slot CheckerModelPaidSlot `json:"slot"` + RegistryEntryID string `json:"registry_entry_id"` + TerminalDigest string `json:"terminal_digest"` + RequestSHA256 string `json:"request_sha256"` +} + +func validCheckerModelRecoveryCandidates(raw json.RawMessage, item *CheckerRetrySourceItem) bool { + var candidates []json.RawMessage + if jsonv2.Unmarshal(raw, &candidates) != nil || candidates == nil || len(candidates) != len(item.ModelRecoveryCandidates) { + return false + } + seen := map[[16]byte]bool{} + for i, value := range candidates { + candidate := &item.ModelRecoveryCandidates[i] + fields, err := contextObject(value, candidate, []string{"parent_attempt_id", "slot", "registry_entry_id", "terminal_digest", "request_sha256"}, nil) + if err != nil || !validUUID(candidate.RegistryEntryID) || !contextPolicyDigest.MatchString(candidate.TerminalDigest) || !contextPolicyDigest.MatchString(candidate.RequestSHA256) { + return false + } + id, valid := uuidIdentity(candidate.ParentAttemptID) + if !valid || seen[id] { + return false + } + seen[id] = true + slot := &candidate.Slot + _, err = contextObject(fields["slot"], slot, []string{"project_id", "assignment_id", "submission_id", "submission_version", "requirement_id", "registry_entry_digest", "configuration_sha256", "profile_id", "profile_version", "profile_digest", "slot_index"}, nil) + if err != nil || !sameUUID(slot.ProjectID, item.Source.ProjectID) || !sameUUID(slot.SubmissionID, item.Source.SubmissionID) || + !validUUID(slot.AssignmentID) || !validUUID(slot.ProfileID) || !boundedInteger(slot.SubmissionVersion, 1, 2_147_483_647) || + slot.SubmissionVersion.Cmp(item.Source.SubmissionVersion) != 0 || !boundedInteger(slot.ProfileVersion, 1, 2_147_483_647) || + !boundedInteger(slot.SlotIndex, 0, 0) || !postPolicyIdentifier.MatchString(slot.RequirementID) { + return false + } + for _, digest := range []string{slot.RegistryEntryDigest, slot.ConfigurationSHA256, slot.ProfileDigest} { + if !contextPolicyDigest.MatchString(digest) { + return false + } + } + } + return true +} diff --git a/cli/internal/api/checker_operations.go b/cli/internal/api/checker_operations.go index b5081bfdd..8777f225c 100644 --- a/cli/internal/api/checker_operations.go +++ b/cli/internal/api/checker_operations.go @@ -239,12 +239,13 @@ type CheckerRetrySuccessor struct { } type CheckerRetrySourceItem struct { - Source CheckerRetrySource `json:"source"` - RecoveryState string `json:"recovery_state"` - CreatedAt string `json:"created_at"` - CompletedAt string `json:"completed_at"` - RetryReceiptID *string `json:"retry_receipt_id"` - Successor *CheckerRetrySuccessor `json:"successor"` + Source CheckerRetrySource `json:"source"` + RecoveryState string `json:"recovery_state"` + CreatedAt string `json:"created_at"` + CompletedAt string `json:"completed_at"` + RetryReceiptID *string `json:"retry_receipt_id"` + Successor *CheckerRetrySuccessor `json:"successor"` + ModelRecoveryCandidates []CheckerModelRecoveryCandidate `json:"model_recovery_candidates"` } type CheckerRetrySourcePage struct { @@ -340,13 +341,16 @@ func (c *Client) CheckerRetrySources(ctx context.Context, project string, limit seen := map[[16]byte]bool{} for i, item := range page.Items { var wire CheckerRetrySourceItem - parts, err := contextObject(itemRaw[i], &wire, []string{"source", "recovery_state", "created_at", "completed_at"}, nil) + parts, err := contextObject(itemRaw[i], &wire, []string{"source", "recovery_state", "created_at", "completed_at", "model_recovery_candidates"}, nil) if err != nil || !requiredNullable(parts, "retry_receipt_id", "successor") || !validCheckerSource(parts["source"], &item.Source, project, "") || !validTime(item.CreatedAt) || !validTime(item.CompletedAt) || (item.RecoveryState != "eligible" && item.RecoveryState != "retried" && item.RecoveryState != "not_current") || (item.RetryReceiptID == nil) != (item.Successor == nil) || (item.RecoveryState == "retried") != (item.RetryReceiptID != nil) { return result, &Failure{Code: "invalid_api_response"} } + if !validCheckerModelRecoveryCandidates(parts["model_recovery_candidates"], &item) { + return result, &Failure{Code: "invalid_api_response"} + } if item.RetryReceiptID != nil { if !validUUID(*item.RetryReceiptID) || !validCheckerSuccessor(parts["successor"], item.Successor) || !validCheckerRetryLineage(&item.Source, item.Successor) { diff --git a/cli/tests/integration/test_checker_operations_http.py b/cli/tests/integration/test_checker_operations_http.py index 74fab4355..6356ce00f 100644 --- a/cli/tests/integration/test_checker_operations_http.py +++ b/cli/tests/integration/test_checker_operations_http.py @@ -364,6 +364,7 @@ def test_retry_lineage_rejects_relational_substitutions_on_receipts_and_history( "completed_at": "2026-10-18T10:01:00Z", "retry_receipt_id": EVIDENCE, "successor": successor(), + "model_recovery_candidates": [], } ], "next_cursor": None, @@ -414,6 +415,7 @@ def test_retry_source_page_and_exact_reason_bound_receipt(cli): "completed_at": "2026-10-18T10:01:00Z", "retry_receipt_id": None, "successor": None, + "model_recovery_candidates": [], } page = {"project_id": PROJECT, "items": [item], "next_cursor": "opaque+/="} with http_fixture() as (origin, response, requests): @@ -432,6 +434,53 @@ def test_retry_source_page_and_exact_reason_bound_receipt(cli): assert parse_qs(request.query) == {"limit": ["1"], "cursor": ["a+/="]} assert requests[-1][0] == "GET" + candidate = { + "parent_attempt_id": EVIDENCE, + "registry_entry_id": ENTRY, + "terminal_digest": SHA, + "request_sha256": SHA, + "slot": { + "project_id": PROJECT, + "assignment_id": ACTOR, + "submission_id": SUBMISSION, + "submission_version": 1, + "requirement_id": "project.evidence", + "registry_entry_digest": SHA, + "configuration_sha256": SHA, + "profile_id": ENTRY, + "profile_version": 1, + "profile_digest": SHA, + "slot_index": 0, + }, + } + model_page = deepcopy(page) + model_page["items"][0]["model_recovery_candidates"] = [candidate] + response["body"] = json.dumps(model_page).encode() + result = invoke(cli, origin, "retry-sources", PROJECT) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == model_page + invalid_candidates = [ + None, + [candidate, candidate], + [candidate | {"provider_response_id": "private-coordinate"}], + [candidate | {"terminal_digest": "not-a-digest"}], + ] + for key, value in ( + ("project_id", ACTOR), + ("submission_id", ACTOR), + ("submission_version", 2), + ("profile_version", None), + ("slot_index", 1), + ("slot_index", True), + ("requirement_id", "invalid space"), + ): + invalid_candidates.append([candidate | {"slot": candidate["slot"] | {key: value}}]) + for candidates in invalid_candidates: + invalid_page = deepcopy(model_page) + invalid_page["items"][0]["model_recovery_candidates"] = candidates + response["body"] = json.dumps(invalid_page).encode() + assert_failure(invoke(cli, origin, "retry-sources", PROJECT), "invalid_api_response") + response.update(status=201, body=json.dumps(receipt()).encode()) for _ in range(2): result = invoke( @@ -458,7 +507,7 @@ def test_retry_source_page_and_exact_reason_bound_receipt(cli): [KEY], {"reason": "Fixed runtime Ă©"}, ) - assert len(requests) == 3 + assert len(requests) == 4 + len(invalid_candidates) def test_retry_refuses_invalid_inputs_foreign_pages_and_unknown_writes(cli): @@ -493,6 +542,7 @@ def test_retry_refuses_invalid_inputs_foreign_pages_and_unknown_writes(cli): "completed_at": "2026-10-18T10:01:00Z", "retry_receipt_id": None, "successor": None, + "model_recovery_candidates": [], } ], "next_cursor": None, diff --git a/docker-compose.agent-loop.yml b/docker-compose.agent-loop.yml index 2b875f6cd..fbb25737b 100644 --- a/docker-compose.agent-loop.yml +++ b/docker-compose.agent-loop.yml @@ -10,10 +10,10 @@ # # The socket lives on a second named volume. Docker Desktop host-shared paths # refuse chmod on Unix sockets, and AF_UNIX paths must stay within 107 bytes. -# An optional backend model proxy uses its own private named volume. Invocation -# relay sockets use a fourth volume mounted in the checker at the volume's -# daemon-visible path so Docker can bind-mount an individual socket into an -# otherwise networkless checker image. +# An optional singleton model-proxy worker uses its own private named volume. +# Invocation relay sockets use a fourth volume mounted in the checker at the +# volume's daemon-visible path so Docker can bind-mount an individual socket +# into an otherwise networkless checker image. x-agent-loop-backend: &agent-loop-backend environment: @@ -34,12 +34,6 @@ services: beat: *agent-loop-backend model-proxy: - extends: - file: docker-compose.yml - service: worker - profiles: ["model-proxy"] - command: [python, -m, app.workers.checker_model_proxy] - stop_grace_period: 125m environment: WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET: /run/ws-model-proxy/model-proxy.sock WORKSTREAM_ARTIFACT_SCRATCH_ROOT: /var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-material/_data diff --git a/docker-compose.yml b/docker-compose.yml index 7aa463c3b..65e835d28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -106,6 +106,19 @@ services: retries: 12 start_period: 20s + model-proxy: + <<: *backend-runtime + profiles: ["model-proxy"] + command: [python, -m, app.workers.checker_model_proxy] + stop_grace_period: 125m + depends_on: + backend: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + beat: <<: *backend-runtime command: diff --git a/docs/architecture_checker_framework.md b/docs/architecture_checker_framework.md index 6c40b8868..99837b3e2 100644 --- a/docs/architecture_checker_framework.md +++ b/docs/architecture_checker_framework.md @@ -392,11 +392,15 @@ atomically persists complete closed member results and a completion event; it does not activate automatic request delivery. ARCH-04B supplies the hidden ART input port: exact consumed Submission bytes, rebuilt manifest, async scoped file access and cleanup, followed by a fresh material-selection check. ARCH-04D2 supplies fresh fixed-service materialization authority before and after I/O. -ARCH-04B2 now supplies hidden typed output storage, byte-free recovery and -flush-only verified binding. The CHECKERS reservation reader now verifies the -exact current request/run/lease and returns zero slots; ART authority remains -deny-only. Controlled nonempty slots prove ART mechanics only. ARCH-04C supports -that exact empty output set and owns durable execution. ARCH-04D2 supplies input, +The existing ART output storage, byte-free recovery and flush-only verified +binding now retain private model-call request/response evidence. CHECKERS +reserves exactly those bounded slots against the stable paid attempt. Request +writes require current execution; response writes may complete an already-paid +effect after its original lease ends. Fresh current authority still gates image +access. The corresponding fixed ART writer/binding actions are active. This +private evidence is separate from the run's normalized member findings; ordinary +images do not acquire model-call authority. ARCH-04C owns durable execution. +ARCH-04D2 supplies input, execute and finalize authority; ARCH-04E/B8 supply the hidden routing operation and completion handler, and 04F2 supplies its remediation branch. Production request/completion registration is supplied by PILOT-04. A returned evaluation value is not a stored current @@ -475,6 +479,13 @@ it is not received package content and its absence is not a work failure. The ordinary image runner remains networkless. Model access is available only to a registered POST image through its exact approved project model profile; a missing image or profile remains an unbound capability suggestion that blocks activation. +The paid-call slot excludes retry generation and execution lease. Workstream +reserves the maximum configured charge before create, retains that reservation +on ambiguous delivery, and retrieves only a known provider ID during recovery. +The image owns prompt interpretation and verdict; the proxy cannot accept work +or spend a revision. Public Operator profile/budget controls and narrowly +authorized terminal-call recovery are described in +[model runtime operations](operations_checker_models.md). The trusted compiler resolves each supported project check to an immutable Operator-registered image, validates bounded typed configuration against that diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 0b03cf038..b0da921f6 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -1748,6 +1748,39 @@ request, run, lease, result, material and original execution receipt. Unprovable retained receipts cause an atomic upgrade refusal; queued work is preserved. Production uses real action-specific AUTH/PREP with fresh live identity checks. +## Checker model calls + +Model-enabled external POST images select one immutable profile through the +locked policy configuration. These records track paid provider effects separately +from `CheckerRun`: losing a execution lease or retrying a checker does not create +another paid effect. + +| Table | Custody | +|---|---| +| `checker_model_profiles` | Immutable project/image/provider/model/bounds/pricing specification and Operator authorization receipt | +| `checker_model_budget_configurations` | Immutable versions of the finite project lifetime ceiling | +| `checker_model_budget_accounts` | Current configuration and serialized lifetime reserved/settled integer USD nanodollars; configuration changes never reset spend | +| `checker_model_call_slots` | Stable project, assignment, Submission/version, requirement, registry digest, configuration hash, exact profile and call-slot identity; excludes retry generation and execution lease | +| `checker_model_call_attempts` | Immutable first request hash, dispatch intent, reservation, provider identity, private ART request/response references, usage and once-only terminal settlement | +| `checker_model_call_recoveries` | Fresh current-run attachment to an existing attempt; cannot authorize a new provider create | +| `checker_model_invocation_grants` | Hashed short-lived capability, exact execution and binding, PostgreSQL expiry and one consumption into the selected attempt/request | + +An attempt progresses from `prepared` to `dispatching`, with uncertain delivery +retaining its full reservation. Authenticated output and complete usage settle +once as `completed` or `known_failed`; proven no-effect becomes `known_no_effect` +and releases the reservation. Only explicit Operator recovery from a terminal +no-effect or fully settled failure may create a child attempt. Unknown delivery +or incomplete usage never permits another create. Late completion may retain +evidence after the originating run ends; fresh current execution is still +required to release that output to an image. + +Migration `0059_checker_model_runtime` extends existing ART put/receipt custody +with `model_call_attempt_id` and exact private request/response slots. Transcript +bytes stay in ART, never these ledger rows or outbox payloads. Existing historical +run-bound ART rows remain retained; new model evidence uses the paid attempt. +The call ledger's private evidence is separate from normalized public checker +findings. See [model runtime operations](operations_checker_models.md). + ## CheckerResult A result is one ordered, selected structural member of its exact owning run. diff --git a/docs/operations_checker_models.md b/docs/operations_checker_models.md index d66289112..db96ae95e 100644 --- a/docs/operations_checker_models.md +++ b/docs/operations_checker_models.md @@ -40,13 +40,48 @@ configuration changes the ceiling without resetting settled spend or outstanding reservations. A project cannot lower its ceiling below the sum already committed. The budget belongs to Workstream-paid evaluation, not contributor compensation. +The public Operator commands all require a fresh authenticated system Operator +grant. Profile and budget publication also require an `Idempotency-Key`: + +| Command | Route | +| --- | --- | +| Publish an immutable image-bound profile | `POST /api/v1/operations/projects/{project_id}/model-profiles` | +| Configure the lifetime project ceiling | `POST /api/v1/operations/projects/{project_id}/model-budget-configurations` | +| Authorize a permitted child of a terminal call | `POST /api/v1/operations/projects/{project_id}/model-call-attempts/{parent_attempt_id}/recovery-children` | + +Recovery-child replay is naturally identified by the immutable parent and exact +candidate, so that route does not require or use an `Idempotency-Key`. + +`GET /api/v1/operations/projects/{project_id}/checker-retry-sources` includes +bounded `model_recovery_candidates` on the matching terminal source run. Each +candidate contains the exact parent, paid slot, registered image, request hash +and terminal digest. Put `parent_attempt_id` in the child route's path and send +the remaining four fields (`slot`, `registry_entry_id`, `request_sha256`, +`terminal_digest`) as its JSON body. After child authorization, use the ordinary +checker retry command to dispatch a new checker execution. + +The request schemas are exposed in OpenAPI. An exact replay uses the original +key and packet; it does not reset spend or bypass current authorization. A new +budget configuration requires its next explicit version. A recovery child also +requires one exact candidate returned by the existing authorized checker-retry +source read; it cannot replace an uncertain call. Child preparation locks the +candidate's current Task, Assignment and Submission custody without requiring an +Operator to race an execution lease. A later checker invocation must still pass the +ordinary current-run, exact-binding and live-lease checks before dispatch. +Profile provisioning does not approve project policy. + ## Runtime and credentials For the local agent-loop compose overlay, enable the `model-proxy` profile and -start its singleton `model-proxy` service. It runs -`python -m app.workers.checker_model_proxy` with -`WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET=/run/ws-model-proxy/model-proxy.sock`. -Ordinary API workers do not own this listener. The backend's existing +start its singleton `model-proxy` service with +`WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET=/run/ws-model-proxy/model-proxy.sock`: + +```bash +docker compose -f docker-compose.yml -f docker-compose.agent-loop.yml \ + --profile backend --profile agent-loop --profile model-proxy up -d model-proxy +``` + +Ordinary API processes do not own this listener. The backend's existing `OPENAI_API_KEY` supplies credentials; never put that key in an image, policy, checker input or command output. Configure the Rust service's `model_proxy_socket` to the same path and `model_relay_root` to its private daemon-visible relay root. @@ -96,7 +131,7 @@ recovery authority. | Known provider ID still pending/unavailable | Original effect and reservation retained | Retrieve that ID through a fresh authorized execution | | Terminal failure without complete usage | Uncertain; reservation retained | Recover complete usage for the original ID; no paid child yet | | Terminal failure with complete settled usage | `known_failed`, immutable evidence | Explicit Operator-authorized child attempt, then checker recovery | -| Completed call but worker/image response lost | Completed effect, original ART bytes and settled cost | Fresh current execution attaches the same cached completion | +| Completed call but image response lost | Completed effect, original ART bytes and settled cost | Fresh current execution attaches the same cached completion | Infrastructure and budget failures never consume a contributor revision. A late provider response may finish the original paid call after its checker lease has diff --git a/docs/operations_roles_permissions.md b/docs/operations_roles_permissions.md index 8d3c37a20..dba766f91 100644 --- a/docs/operations_roles_permissions.md +++ b/docs/operations_roles_permissions.md @@ -165,6 +165,9 @@ Recovery is a separate, reasoned, audited path. | Submission gate repair (planned; unavailable) | `operations.submission_gate.repair` | Operator | | Checker recovery-source discovery (active public read) | `operations.status.read` via `operations.checker_retry_source.list` | System Operator for one exact project | | Checker terminal-failure retry (active public operation) | `operations.checker.retry` | System Operator under exact terminal-failure guards | +| Immutable project model profile provisioning | `project.model_profile.manage` via `project.model_profile.provision` | System Operator for the exact project and registered image | +| Lifetime project model budget configuration | `project.model_budget.manage` via `project.model_budget.configure` | System Operator; preserves all outstanding reservations and settled spend | +| Model-call recovery child authorization | `project.model_call.recovery_child.authorize` | System Operator for an exact proven no-effect or fully settled failed parent; uncertain delivery is ineligible | | Review lease force release | `review.lease.force_release` | Operator under WS-REV-001 | | Checker revision-context repair (active public operation) | `project.task.manage` via `project.task.revision.repair` | Covered Project Manager | | Checker revision expiry (active internal operation) | `task.revision.expire` | Exact fixed service `workstream.task.revision_expirer` | diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 9e8a4ab0d..aedbc3683 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -77,6 +77,16 @@ lifecycle is proven. See the ## Status Vocabulary +The project-image model runtime adds immutable Operator-provisioned model +profiles, a finite lifetime project budget, private ART call evidence and +recovery of the same paid effect across checker retries. Images keep their own +rubrics and receive only an invocation-local socket through the generic Rust +service. This provides the model capability needed by the real Terminal Systems +POST proposal; it does not establish that project's activation, model quality, +gVisor qualification or live contribution journey. See +[model runtime operations](operations_checker_models.md) and its +[bounded change](../.commitrail/changes/checker-model-runtime.md). + The public automated-acceptance journey passed its bounded connected local drill from public guide inputs with recorded inference, local Flow-compatible auth and real PostgreSQL, MinIO, Redis, prefork Celery and Beat. Both policy outcomes, @@ -489,6 +499,7 @@ cannot be reused as post-submission review-gate evidence. See the | Lifecycle stage | Intended merged status | What is already proven | What remains before v0.1 | | --- | --- | --- | --- | +| Project-image model evaluation | **Budgeted runtime and public Operator controls** | Exact approved project/image/profile selection; single-use private callback; stable paid-call identity; atomic lifetime reservation; private ART request/response evidence; retrieve-only recovery after uncertain dispatch; once-only settlement | Qualify each project's POST image and model rubric, exact pricing, intended isolation and capped live calls; broader PM spend reads and setup-agent billing remain separate | | Identity and actor resolution | **Live foundation** | Flow-token verification; canonical ActorProfile and ActorIdentityLink; human/service separation; lifecycle controls; canonical `/actors/me` self-read with duplicate `/auth/me` removed; suspension denies both self-read and self-update; CLI privileged exact-actor/profile and identity-link reads and explicit separate profile/link lifecycle writes remain distinct from self-service and backend guards | Final end-to-end operational and conformance proof | | Authorization kernel | **Live foundation** | Closed action/permission catalogues; deny-by-default evaluation; grants; exact-project, privacy-safe candidate read now consumed by the CLI; fixed services; rate controls; opaque transaction-bound PREP; atomic decision evidence; CLI scope-filtered administrative grant/catalogue reads and explicit keyed grant issue/revoke consume existing public APIs without local authority decisions | Activate only the remaining owner-proven TASK, checker, REV, and CON boundaries; remove obsolete authority after replacement paths are live | | Project Guide source custody | **Live foundation** | Guide creation declares documents and task examples; public document upload; immutable internal metadata snapshots; draft guide and review/revision policy writes with fresh-authorized exact replay of original receipts; exact run-scoped setup reads and assigned-task locked-original reads; S3-backed originals and isolated agent document inspection; exact document generation through separate manager approvals and public exact-authorized CP07 activation; Project Manager guide list, guide detail, activation readiness dry run and verified original read-back (PILOT-READ-3); the CLI exposes existing administrative active-chain and retained sufficiency/artifact-policy reads, keyed human sufficiency writes, and separate manual artifact-policy draft create/update with backend-owned policy decisions | Public original upload, task examples, Celery setup and separate approvals are connected locally with recorded inference; prove configured live inference and readers in the intended environment; manual drafts are not compiled activation proposals; deployed CLI remains separate | @@ -1259,7 +1270,8 @@ Delivered foundations (bounded local journey proven; no deployment claim) task/assignment/Submission lineage + public ZIP intake/admission-backed creation ARCH-03D exact approved historical guide through durable intake handoff ARCH-04B hidden verified Submission input - ARCH-04B2 hidden checker output store/recovery/binding (authority deny-only) + private model-call ART request/response custody with exact fixed-service authority + immutable project model profiles + lifetime budget + stable paid-effect recovery ARCH-04C hidden durable execution/current results + atomic completion event shared dispatcher + exact-authorized hidden assignment invalidation ARCH-03C2 invalidation producer + first handler registration @@ -1402,6 +1414,11 @@ v0.1 is not ready until all of the following are true: Flow/AWS identities, gVisor execution and measured limits in the deployed environment; configuration rendering and local source-image builds are only prerequisites. +- Qualify model-enabled POST images with their approved profiles, current exact + pricing and finite lifetime project budget. Prove invocation sockets under the + intended isolation and recover interrupted paid effects without another + create. The Terminal Systems live rehearsal has a US$10 aggregate cap; + offline transport/ledger tests do not establish its full project journey. - Complete the client-only #611 artifact diagnosis/retry CLI against the exact-authorized public API; API activation alone does not deliver the CLI or prove hosted S3 operation. diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 7f074d8de..430f07cbd 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -295,7 +295,9 @@ registration, hidden ART behavior/resource composition, then dedicated AUTH evaluator integration and activation. ART never writes availability. AUTH-12, AUTH-14, and AUTH-15 are not alternate artifact activation paths. -These are 75 approved `PermissionId` values. `ActionId` values are a separate +The initial list above contains 75 approved `PermissionId` values. The current +closed catalogue contains 91 permissions, including the nine model-runtime +permissions described below. `ActionId` values are a separate closed registry layer and are not included in that permission count. AUTH-05A's typed and PostgreSQL audit registry accepts the exact historical 49. The three approved Operator recovery identifiers, 16 artifact identifiers, @@ -671,13 +673,13 @@ remain planned and unavailable, and add no migration. | `artifact.guide_source.read` | `artifact.guide_source.read` | fixed guide-reader service | exact committed document version and fenced compilation attempt | `03` | | `artifact.submission_bundle.prepare` | `submission.create` | assigned contributor | exact task/admission context | `04C2` | | `artifact.submission.binding.create` | `artifact.binding.create` | fixed binding service | submission | `05` | -| `artifact.checker_output.binding.create` | `artifact.binding.create` | fixed binding service | checker run | `06B` | +| `artifact.checker_output.binding.create` | `artifact.binding.create` | fixed binding service | exact paid model-call attempt, private request/response slot and verified content | `06B`, model runtime | | `artifact.verification.execute` | `artifact.verification.execute` | fixed verifier service | verification job | `02D` | | `artifact.pending_work.scan` | `artifact.pending_work.scan` | fixed scheduler service | system pending-work scope | `02D` | | `artifact.put_attempt.resolve` | `artifact.put_attempt.resolve` | fixed put-resolver service | put attempt | `02D` | | `artifact.pre_submit.checker_input.materialize` | `artifact.checker_input.materialize` | fixed materializer service | exact task, assignment, project/guide/snapshot and locked policy lineage plus plan/catalogue/archive/semantic-manifest identities and current process-local prepared generation; no scratch path/handle is serialized | `04B2/04B3` + `XINT-002-06A` | | `artifact.post_submit.checker_input.materialize` | `artifact.checker_input.materialize` | fixed materializer service | checker run and immutable bindings | `06A` | -| `artifact.checker_output.write` | `artifact.checker_output.write` | fixed checker-output service | checker run | `06B` | +| `artifact.checker_output.write` | `artifact.checker_output.write` | fixed checker-output service | exact paid model-call attempt and private request/response slot; request requires current execution, late response requires retained effect custody | `06B`, model runtime | | `artifact.review_packet.materialize` | `artifact.review_packet.materialize` | fixed materializer service | exact active lease and Submission packet | `07A` | | `artifact.review_evidence.binding.create` | `artifact.binding.create` | fixed binding service | future REV-owned evidence slot; no approved v0.1 activation | future | @@ -710,7 +712,7 @@ closed: | `workstream.task.revision_expirer` | active: `task.revision.expire`, `task.revision.recover` | | `workstream.task.post_submit_router` | active: `task.post_submit.route` | | `workstream.checker.pre_submit` | active: `checker.pre_submit.execute`, `checker.pre_submit.finalize` | -| `workstream.checker.post_submit` | active: `checker.post_submit.execute`, `checker.post_submit.finalize` | +| `workstream.checker.post_submit` | active: `checker.post_submit.execute`, `checker.post_submit.finalize`, `checker.model_call.grant`, `checker.model_call.prepare`, `checker.model_call.dispatch`, `checker.model_call.recover`, `checker.model_call.observe`, `checker.model_call.complete` | | `workstream.compensation.adapter` | target-only; no action membership | The hidden 04B2 prepared resource first locks fixed-service authority using @@ -792,6 +794,12 @@ exact fixed-service authority and manifest. | `task.revision.recover` | `workstream.task.revision_expirer` | Exact retained routing manifest, assigned contributor, locked task context, request digest and optional preparation ID/digest pair; fresh fixed-service authority. Recovery retains the source and exact running/paused clock; exact already-scheduled work is a no-op | PILOT-08 implements bounded retained-source recovery of missing preparation or expiry intent; it may freeze unchanged or blocked context, never authorize an automatic rebase or reset the window | | `checker.post_submit.execute` | `workstream.checker.post_submit` | Immutable Submission/request/generation, locked compiled policy, attempt and admitted service; exact pre-I/O authority | Implemented by ARCH-04C/04D2 and invoked by the registered selected request handler | | `checker.post_submit.finalize` | `workstream.checker.post_submit` | Exact execution request/fence, accepted result digest, retained material and original execute receipt; fresh post-I/O authority and atomic evidence; current outputs are empty | Implemented by ARCH-04C/04D2 and invoked by the registered selected request handler | +| `checker.model_call.grant` | `workstream.checker.post_submit` | Exact current POST execution, locked project/image/profile, stable paid slot, invocation request digest and private token hash; PostgreSQL lease expiry and single consumption | Project model runtime | +| `checker.model_call.prepare` | `workstream.checker.post_submit` | Current POST execution and stable paid slot with immutable first request hash; AUTH then TASK then CHECKERS custody | Project model runtime | +| `checker.model_call.dispatch` | `workstream.checker.post_submit` | Current execution, prepared attempt and verified request evidence; atomic lifetime budget reservation and dispatch intent before provider I/O | Project model runtime | +| `checker.model_call.recover` | `workstream.checker.post_submit` | Current exact execution attaching to an existing paid effect; permits retrieval or cached attachment, never a new create | Project model runtime | +| `checker.model_call.observe` | `workstream.checker.post_submit` | Exact retained attempt, internal request identity and provider observation digest; may retain a late provider ID independently of the original lease | Project model runtime | +| `checker.model_call.complete` | `workstream.checker.post_submit` | Exact retained attempt and terminal digest, verified private response evidence and complete usage for settlement; a late completion cannot authorize stale image access | Project model runtime | | `task.post_submit.route` | `workstream.task.post_submit_router` | Committed completion event/claim, exact current CHECKER result/fence, immutable Submission, locked ReviewPolicy and TASK pre-review state. `allow_review` with true permits only TASK `review_pending`; `allow_review` with false binds exact accepted TASK effects for shared FinalAcceptance. `needs_revision` under either mode permits TASK remediation when revisions remain, or exact final-attempt exhaustion and release to ready; neither creates REV/CON effects. Never a human review decision or generic CON write | 04E2-B delivers canonical fixed-router PREP consumption and actual receipt custody for complete outcomes; ARCH-04F2 extends that same operation and receipt to exact checker remediation. SQL rejects orphan or incomplete outcomes and failure-to-acceptance substitution. Selected request and completion handlers are registered in production composition. False/pass effects require a live REV generation; the typed pre-effect non-live refusal rolls back and uses bounded OUTBOX retry. 04F3 adds exact-cause replacement under existing AUTH actions; 04F4 adds separate Operator retry authority through CHECKERS. | Each action maps to the identically named permission in this table and only @@ -800,6 +808,26 @@ cannot borrow another row. Existing ART materializer/output identities retain their separate permissions and allow evidence. No prepared handle crosses a commit, I/O, lease wait or message boundary. +### Project model administration + +Only a current system-scoped human Operator grant admits these public commands. +Project Manager approval of the image configuration is separate and cannot +provision a model profile, increase a budget or authorize another paid attempt. +Each command binds fresh authority to the exact project, actor, request and +immutable specification or parent receipt; idempotent replay rechecks authority. + +| ActionId | PermissionId | Exact target | +|---|---|---| +| `project.model_profile.provision` | `project.model_profile.manage` | Project, exact registered image and immutable provider/model/bounds/pricing profile | +| `project.model_budget.configure` | `project.model_budget.manage` | Project and immutable budget configuration version; lifetime reservations and settled spend are never reset | +| `project.model_call.recovery_child.authorize` | `project.model_call.recovery_child.authorize` | Exact current execution and terminal parent receipt; only proven no-effect or fully settled known failure permits a child | + +An uncertain delivery is ineligible for a child. Model/provider budget failures +are infrastructure outcomes, not contributor-fixable failures. Private evidence +is retained through ART's existing output writer and binding authorities, not a +new transcript read permission. See [model runtime operations](operations_checker_models.md) +for public routes, effect recovery and verification boundaries. + Human task actions and existing permission mappings are enumerated in the [ARCH-03C manifest](../.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03C-auth-task-readiness.md#exact-surfaceaction-manifest). In particular, `task.start` uses existing `task.claim` entitlement plus exact diff --git a/external_checkers/README.md b/external_checkers/README.md index 8128236a5..c3f68dd33 100644 --- a/external_checkers/README.md +++ b/external_checkers/README.md @@ -73,8 +73,9 @@ access to the global proxy endpoint. The service wraps the prompt with the invocation's private grant and request digest. It offers one call slot and keeps the checker container on `--network none`. -The dedicated singleton model-proxy worker is optional. Its `model-proxy` -Compose profile sets +The dedicated singleton model-proxy worker is optional. Add `model-proxy` to +the existing backend and agent-loop Compose profiles to enable it. That profile +sets `WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET=/run/ws-model-proxy/model-proxy.sock` and binds the listener; the ordinary API, task worker and beat processes do not own or mount that socket. An empty value outside the model-proxy process leaves the @@ -87,17 +88,40 @@ root. Enabling only one side does not activate model calls. The proxy and relay roots must be canonical mode-`0700` directories owned by the shared service UID and GID. The global proxy socket and each invocation socket -are mode `0600`. The backend has read-write access to the proxy volume, the Rust -service connects through its read-only volume mount and creates invocation -sockets in the relay volume, and a checker receives only its individual socket -file. A different service/sandbox identity is rejected because this path does -not rely on privileged ownership changes. +are mode `0600`. The singleton model-proxy worker has read-write access to the +proxy volume, the Rust service connects through its read-only volume mount and +creates invocation sockets in the relay volume, and a checker receives only its +individual socket file. A different service/sandbox identity is rejected +because this path does not rely on privileged ownership changes. Docker development proves only the ordinary runtime path. A gVisor `runsc` deployment must separately enable `host-uds=open` before a mounted host Unix socket can work. This repository does not claim that the model relay has been qualified under gVisor. +The opt-in +[`model_relay_docker.py`](workstream-checker-service/tests/model_relay_docker.py) +probe drives a real Docker-development checker from its invocation-local socket, +through the Rust relay, to a fake private global proxy. It records the created +checker before cleanup and verifies `--network none`, a read-only root filesystem, +read-only material and socket mounts, the closed proxy request and exact reply, +and removal of the checker and invocation directory. It uses only already-local +`alpine:3.20` and `registry:2` images, creates a small disposable checker layer, +and makes no provider call: + +```console +CARGO_TARGET_DIR=/dev/shm/workstream-checker-target \ + cargo build --locked -p workstream-checker-service +TMPDIR=/dev/shm python3 \ + workstream-checker-service/tests/model_relay_docker.py \ + --service /dev/shm/workstream-checker-target/debug/workstream-checker-service +``` + +Run those commands from `external_checkers/`. The probe never pulls images, +scans Docker state for cleanup, or qualifies the gVisor path. Its fake reply is +transport evidence only; it does not prove provider execution, budget settlement, +or a completed product model-evaluation journey. + ## From guide to an active project Use the real guide and task examples as setup input. Inspect the resulting diff --git a/external_checkers/workstream-checker-service/tests/model_relay_docker.py b/external_checkers/workstream-checker-service/tests/model_relay_docker.py new file mode 100644 index 000000000..87080ef80 --- /dev/null +++ b/external_checkers/workstream-checker-service/tests/model_relay_docker.py @@ -0,0 +1,791 @@ +"""Opt-in Docker-dev proof for the invocation-local model relay. + +The probe uses an already-local base image and registry image, builds only a +small disposable checker layer, and never contacts a model provider. It starts +one fake private global proxy and drives the real Rust service through one +post-submit invocation. Every created container and image reference is recorded +and removed explicitly; the probe never scans or prunes Docker state. + +Usage: + TMPDIR=/dev/shm python3 model_relay_docker.py \ + --service /absolute/path/to/workstream-checker-service +""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +import socket +import struct +import subprocess +import tarfile +import tempfile +import threading +import time + + +PROTOCOL = "external_checker_service.v1" +GRANT_PROTOCOL = "workstream.external_checker_material_grant.v1" +MODEL_PROTOCOL = "workstream.checker_model_proxy.v1" +MAX_FRAME = 1024 * 1024 +MODEL_GRANT_ID = "model_0123456789abcdef0123456789abcdef" +MATERIAL_GRANT_ID = "extract_0123456789abcdef0123456789abcdef" +PROMPT = "docker relay proof" +MODEL_REPLY = { + "protocol_version": MODEL_PROTOCOL, + "outcome": "completed", + "output": "docker-relay-ok", + "receipt_digest": "sha256:" + "e" * 64, + "failure_code": None, +} + + +def canonical(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode() + + +def digest(value: object) -> str: + return "sha256:" + hashlib.sha256(canonical(value)).hexdigest() + + +def run(docker: str, *arguments: str, timeout: int = 60) -> str: + return subprocess.check_output( + [docker, *arguments], text=True, stderr=subprocess.PIPE, timeout=timeout + ).strip() + + +def inspect_container(docker: str, identity: str) -> dict | None: + result = subprocess.run( + [docker, "container", "inspect", identity], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode: + run(docker, "info", "--format", "{{.ID}}") + if "No such" not in result.stderr: + raise RuntimeError(result.stderr) + return None + return json.loads(result.stdout)[0] + + +def read_exact(connection: socket.socket, length: int) -> bytes: + body = bytearray() + while len(body) < length: + part = connection.recv(length - len(body)) + if not part: + raise OSError("short frame") + body.extend(part) + return bytes(body) + + +def read_frame(connection: socket.socket, maximum: int = MAX_FRAME) -> bytes: + length = struct.unpack(">I", read_exact(connection, 4))[0] + if not 0 < length <= maximum: + raise RuntimeError("invalid frame length") + return read_exact(connection, length) + + +def write_frame(connection: socket.socket, body: bytes) -> None: + if not 0 < len(body) <= MAX_FRAME: + raise RuntimeError("invalid frame body") + connection.sendall(struct.pack(">I", len(body)) + body) + + +def extract_registry_binary(docker: str, image: str, root: Path) -> Path: + archive = root / "registry-image.tar" + subprocess.check_call( + [docker, "image", "save", "--output", str(archive), image], timeout=60 + ) + content: bytes | None = None + with tarfile.open(archive) as outer: + manifest_member = outer.extractfile("manifest.json") + if manifest_member is None: + raise RuntimeError("local registry image manifest is missing") + manifests = json.load(manifest_member) + selected = next( + ( + item + for item in manifests + if image in item.get("RepoTags", []) + or image.split(":", 1)[0] in item.get("RepoTags", []) + ), + manifests[0] if len(manifests) == 1 else None, + ) + if selected is None: + raise RuntimeError("local registry image is ambiguous") + for layer_name in selected["Layers"]: + layer_member = outer.extractfile(layer_name) + if layer_member is None: + raise RuntimeError("local registry image layer is missing") + with tarfile.open(fileobj=io.BytesIO(layer_member.read())) as layer: + for member in layer: + normalized = member.name.removeprefix("./").removeprefix("/") + if normalized == "bin/.wh.registry": + content = None + elif normalized == "bin/registry" and member.isfile(): + source = layer.extractfile(member) + if source is None: + raise RuntimeError("local registry binary is unreadable") + content = source.read() + archive.unlink() + if content is None: + raise RuntimeError("local registry binary is missing") + registry = root / "registry" + registry.write_bytes(content) + registry.chmod(0o500) + return registry + + +def start_registry(binary: Path, root: Path) -> tuple[subprocess.Popen, int]: + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + storage = root / "registry-data" + storage.mkdir(mode=0o700) + config = root / "registry.yml" + config.write_text( + "version: 0.1\n" + "log:\n level: error\n" + f"storage:\n filesystem:\n rootdirectory: {storage}\n" + f"http:\n addr: 127.0.0.1:{port}\n", + encoding="utf-8", + ) + process = subprocess.Popen( + [str(binary), "serve", str(config)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError("disposable registry failed to start") + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.5): + return process, port + except OSError: + time.sleep(0.05) + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + raise RuntimeError("disposable registry readiness timed out") + + +def wait_for_service(process: subprocess.Popen, address: Path) -> None: + deadline = time.monotonic() + 60 + last_error: Exception | None = None + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError(process.stderr.read()) + try: + with socket.socket(socket.AF_UNIX) as connection: + connection.settimeout(15) + connection.connect(str(address)) + write_frame( + connection, + canonical({"protocol_version": PROTOCOL, "operation": "health"}), + ) + response = json.loads(read_frame(connection, 96 * 1024)) + if response.get("status") == "ready": + return + except (OSError, ValueError, RuntimeError) as error: + last_error = error + time.sleep(0.1) + raise RuntimeError(f"service readiness timed out: {last_error}") + + +def schema(schema_id: str) -> dict: + document = {"type": "object"} + return { + "schema_id": schema_id, + "schema_version": "v1", + "document": document, + "schema_sha256": digest(document), + } + + +def request_and_manifest(image_digest: str) -> tuple[dict, dict]: + def identifier(byte: str) -> str: + return f"{byte * 8}-0000-4000-8000-000000000000" + + def sha(byte: str) -> str: + return "sha256:" + byte * 64 + + semantic = digest( + {"schema_version": "workstream.submission_bundle_manifest.v1", "entries": []} + ) + registry = { + "capability_id": "probe.model_relay", + "capability_version": "v1", + "phase": "post_submit", + "image_digest": image_digest, + "configuration_schema": schema("probe.model_relay.configuration"), + "input_schema": schema("external_checker_post_submit_input"), + "output_schema": schema("external_checker_result"), + "resources": { + "cpu_millis": 500, + "memory_bytes": 64 * 1024 * 1024, + "deadline_ms": 30_000, + "maximum_output_bytes": 32_768, + }, + "registry_entry_id": identifier("f"), + "entry_digest": sha("0"), + "registration_operation_id": identifier("a"), + "registered_by_actor_profile_id": identifier("b"), + "authorization_decision_event_id": identifier("c"), + "created_at": "2026-10-23T00:00:00Z", + } + entry = dict(registry) + for field in ( + "registry_entry_id", + "entry_digest", + "registration_operation_id", + "registered_by_actor_profile_id", + "authorization_decision_event_id", + "created_at", + ): + entry.pop(field) + registry["entry_digest"] = digest(entry) + identity = { + "phase": "post_submit", + "project_id": identifier("1"), + "task_id": identifier("2"), + "assignment_id": identifier("3"), + "submission_id": identifier("4"), + "submission_version": 2, + "evaluation_request_id": identifier("5"), + "evaluation_request_digest": sha("5"), + "evaluation_generation": 3, + "attempt_id": identifier("6"), + "result_id": identifier("7"), + "lease_id": identifier("8"), + "lease_generation": 4, + "lease_expires_at": "2026-10-23T01:00:00+00:00", + } + material = { + "phase": "post_submit", + "role": "submission_archive", + "submission_id": identity["submission_id"], + "submission_version": identity["submission_version"], + "binding_id": identifier("9"), + "content_id": identifier("a"), + "replica_id": identifier("b"), + "content_sha256": sha("c"), + "byte_count": 0, + "media_type": "application/zip", + "evidence_id": identifier("c"), + "verification_receipt_id": identifier("d"), + "verification_job_id": identifier("e"), + "verification_generation": 5, + "namespace_fingerprint": sha("d"), + "semantic_manifest_sha256": semantic, + } + configuration: dict[str, object] = {} + checker_input: dict[str, object] = {} + request = { + "schema_version": "external_checker_request.v1", + "registry": registry, + "identity": identity, + "configuration": configuration, + "configuration_sha256": digest(configuration), + "input": checker_input, + "input_sha256": digest(checker_input), + "materials": [material], + } + request["request_digest"] = digest(request) + manifest = { + "protocol_version": GRANT_PROTOCOL, + "grant_id": MATERIAL_GRANT_ID, + "request_digest": request["request_digest"], + "phase": "post_submit", + "submission_id": identity["submission_id"], + "submission_version": identity["submission_version"], + "evaluation_request_id": identity["evaluation_request_id"], + "evaluation_request_digest": identity["evaluation_request_digest"], + "evaluation_generation": identity["evaluation_generation"], + "attempt_id": identity["attempt_id"], + "result_id": identity["result_id"], + "lease_id": identity["lease_id"], + "lease_generation": identity["lease_generation"], + "lease_expires_at": identity["lease_expires_at"], + "binding_id": material["binding_id"], + "content_id": material["content_id"], + "replica_id": material["replica_id"], + "content_sha256": material["content_sha256"], + "byte_count": material["byte_count"], + "evidence_id": material["evidence_id"], + "verification_receipt_id": material["verification_receipt_id"], + "verification_job_id": material["verification_job_id"], + "verification_generation": material["verification_generation"], + "namespace_fingerprint": material["namespace_fingerprint"], + "semantic_manifest_sha256": semantic, + "directories": [], + "files": [], + } + manifest["binding_digest"] = digest(manifest) + return request, manifest + + +def write_checker_sources(root: Path) -> tuple[Path, Path]: + client_source = root / "model-client.c" + client = root / "model-client" + client_source.write_text( + r"""#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int transfer(int fd, void *buffer, size_t length, int writing) { + size_t offset = 0; + while (offset < length) { + ssize_t count = writing ? write(fd, (char *)buffer + offset, length - offset) + : read(fd, (char *)buffer + offset, length - offset); + if (count < 0 && errno == EINTR) continue; + if (count <= 0) return -1; + offset += (size_t)count; + } + return 0; +} + +int main(void) { + static const char body[] = "{\"prompt\":\"docker relay proof\"}"; + struct sockaddr_un address = {0}; + int descriptor = socket(AF_UNIX, SOCK_STREAM, 0); + if (descriptor < 0) return 20; + address.sun_family = AF_UNIX; + if (strlen("/work/model.sock") >= sizeof(address.sun_path)) return 21; + strcpy(address.sun_path, "/work/model.sock"); + if (connect(descriptor, (struct sockaddr *)&address, sizeof(address)) != 0) return 22; + uint32_t length = htonl((uint32_t)(sizeof(body) - 1)); + if (transfer(descriptor, &length, sizeof(length), 1) || + transfer(descriptor, (void *)body, sizeof(body) - 1, 1)) return 23; + if (transfer(descriptor, &length, sizeof(length), 0)) return 24; + length = ntohl(length); + if (length == 0 || length > 1024 * 1024) return 25; + char *reply = malloc(length); + if (!reply || transfer(descriptor, reply, length, 0)) return 26; + if (write(STDOUT_FILENO, reply, length) != (ssize_t)length) return 27; + free(reply); + close(descriptor); + return 0; +} +""", + encoding="utf-8", + ) + subprocess.check_call( + ["cc", "-static", "-Os", "-s", str(client_source), "-o", str(client)], + timeout=60, + ) + client.chmod(0o555) + checker = root / "checker.sh" + expected_reply = canonical(MODEL_REPLY).decode() + checker.write_text( + f"""#!/bin/sh +set -eu +request="$(cat)" +case "$request" in + *'"model_grant"'*|*'"grant_id"'*|*'PROVIDER_SECRET_PROOF'*|*'model-proxy.sock'*) exit 40 ;; +esac +reply="$(/model-client)" +[ "$reply" = '{expected_reply}' ] || exit 41 +request_digest="$(printf %s "$request" | sed -n 's/.*"request_digest":"\\([^"]*\\)".*/\\1/p')" +registry_digest="$(printf %s "$request" | sed -n 's/.*"entry_digest":"\\([^"]*\\)".*/\\1/p')" +registry_id="$(printf %s "$request" | sed -n 's/.*"registry_entry_id":"\\([^"]*\\)".*/\\1/p')" +case "$request_digest:$registry_digest:$registry_id" in + sha256:????????????????????????????????????????????????????????????????:sha256:????????????????????????????????????????????????????????????????:?*) ;; + *) exit 42 ;; +esac +body="$(printf '{{\"findings\":[],\"infrastructure_failure_code\":null,\"outcome\":\"completed\",\"phase\":\"post_submit\",\"registry_entry_digest\":\"%s\",\"registry_entry_id\":\"%s\",\"request_digest\":\"%s\",\"schema_version\":\"external_checker_result.v1\",\"verdict\":\"passed\"}}' "$registry_digest" "$registry_id" "$request_digest")" +result_digest="$(printf %s "$body" | sha256sum | cut -d' ' -f1)" +printf '{{\"findings\":[],\"infrastructure_failure_code\":null,\"outcome\":\"completed\",\"phase\":\"post_submit\",\"registry_entry_digest\":\"%s\",\"registry_entry_id\":\"%s\",\"request_digest\":\"%s\",\"result_digest\":\"sha256:%s\",\"schema_version\":\"external_checker_result.v1\",\"verdict\":\"passed\"}}' "$registry_digest" "$registry_id" "$request_digest" "$result_digest" +""", + encoding="utf-8", + ) + checker.chmod(0o555) + return client, checker + + +def fake_proxy( + address: Path, expected_digest: str, result: dict, ready: threading.Event +) -> None: + try: + with socket.socket(socket.AF_UNIX) as listener: + listener.bind(str(address)) + address.chmod(0o600) + listener.listen(1) + ready.set() + listener.settimeout(45) + connection, _ = listener.accept() + with connection: + connection.settimeout(15) + request = json.loads(read_frame(connection)) + expected = { + "protocol_version": MODEL_PROTOCOL, + "grant_id": MODEL_GRANT_ID, + "request_digest": expected_digest, + "prompt": PROMPT, + } + if request != expected: + raise RuntimeError("proxy received a different closed request") + result["request"] = request + write_frame(connection, canonical(MODEL_REPLY)) + except BaseException as error: + result["error"] = error + ready.set() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--service", required=True, type=Path) + parser.add_argument("--base-image", default="alpine:3.20") + parser.add_argument("--registry-image", default="registry:2") + args = parser.parse_args() + service = args.service.resolve(strict=True) + docker = str(Path(shutil.which("docker") or "/usr/bin/docker").resolve(strict=True)) + uid, gid = os.geteuid(), os.getegid() + if uid == 0 or gid == 0: + raise RuntimeError( + "probe requires the same non-root UID/GID used by the service" + ) + + # These inspect calls must succeed locally. The probe never pulls either image. + run(docker, "image", "inspect", args.base_image) + run(docker, "image", "inspect", args.registry_image) + nonce = os.urandom(8).hex() + build_name = f"ws-model-relay-build-{nonce}" + local_tag = f"ws-model-relay-proof:{nonce}" + registry_tag: str | None = None + created_image_id: str | None = None + containers: list[str] = [] + container_names = (build_name,) + checker_id: str | None = None + inspection_path: Path | None = None + service_process: subprocess.Popen | None = None + registry_process: subprocess.Popen | None = None + + temporary_parent = os.environ.get("TMPDIR") + with tempfile.TemporaryDirectory( + prefix="ws-model-relay-", dir=temporary_parent + ) as temporary: + root = Path(temporary).resolve() + try: + client, checker = write_checker_sources(root) + build_id = run(docker, "create", "--name", build_name, args.base_image) + containers.append(build_id) + run(docker, "cp", str(client), f"{build_id}:/model-client") + run(docker, "cp", str(checker), f"{build_id}:/checker.sh") + created_image_id = run( + docker, + "commit", + "--change", + 'ENTRYPOINT ["/checker.sh"]', + "--change", + "CMD []", + build_id, + local_tag, + timeout=120, + ) + run(docker, "rm", build_id) + containers.remove(build_id) + + registry_binary = extract_registry_binary(docker, args.registry_image, root) + registry_process, port = start_registry(registry_binary, root) + repository = f"127.0.0.1:{port}/ws-model-relay-proof" + registry_tag = f"{repository}:{nonce}" + run(docker, "tag", local_tag, registry_tag) + run(docker, "push", registry_tag, timeout=120) + image = json.loads(run(docker, "image", "inspect", registry_tag))[0] + reference = next( + item + for item in image["RepoDigests"] + if item.startswith(repository + "@sha256:") + ) + platform_digest = reference.split("@", 1)[1] + + request, manifest = request_and_manifest(platform_digest) + socket_root = root / "service" + material_root = root / "material" + proxy_root = root / "proxy" + relay_root = root / "relay" + for directory in (socket_root, material_root, proxy_root, relay_root): + directory.mkdir(mode=0o700) + workspaces = material_root / "workspaces" + grant_root = workspaces / MATERIAL_GRANT_ID + workspace = grant_root / "workspace" + workspaces.mkdir(mode=0o700) + grant_root.mkdir(mode=0o700) + workspace.mkdir(mode=0o500) + manifest_path = grant_root / ".external-checker-grant.json" + manifest_path.write_bytes(canonical(manifest)) + manifest_path.chmod(0o400) + + proxy_address = proxy_root / "model-proxy.sock" + proxy_result: dict[str, object] = {} + proxy_ready = threading.Event() + proxy_thread = threading.Thread( + target=fake_proxy, + args=( + proxy_address, + request["request_digest"], + proxy_result, + proxy_ready, + ), + daemon=True, + ) + proxy_thread.start() + if not proxy_ready.wait(5) or "error" in proxy_result: + raise RuntimeError( + f"fake proxy startup failed: {proxy_result.get('error')}" + ) + + inspection_path = root / "container-inspection.json" + wrapper = root / "docker-wrapper.py" + wrapper.write_text( + "#!/usr/bin/python3\n" + "import json, pathlib, subprocess, sys\n" + f"docker={docker!r}\n" + f"proof=pathlib.Path({str(inspection_path)!r})\n" + "result=subprocess.run([docker,*sys.argv[1:]], stdin=sys.stdin.buffer, " + "stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n" + "if result.returncode == 0 and len(sys.argv)>1 and sys.argv[1]=='create':\n" + " identity=result.stdout.decode().strip()\n" + " inspected=subprocess.check_output([docker,'container','inspect',identity])\n" + " proof.write_bytes(inspected)\n" + "sys.stdout.buffer.write(result.stdout)\n" + "sys.stderr.buffer.write(result.stderr)\n" + "raise SystemExit(result.returncode)\n", + encoding="utf-8", + ) + wrapper.chmod(0o700) + config = { + "socket_path": str(socket_root / "checker.sock"), + "model_proxy_socket": str(proxy_address), + "model_relay_root": str(relay_root), + "material_root": str(material_root), + "docker_binary": str(wrapper), + "runtime": "runc", + "isolation_mode": "docker-dev", + "operating_system": image["Os"], + "architecture": image["Architecture"], + "sandbox_uid": uid, + "sandbox_gid": gid, + "cache": [ + { + "repository": repository, + "platform_manifest_digest": platform_digest, + "platform_manifest_media_type": "application/vnd.docker.distribution.manifest.v2+json", + "platform_manifest_byte_count": 1, + "operating_system": image["Os"], + "architecture": image["Architecture"], + "config_image_id": image["Id"], + } + ], + } + config_path = root / "service.json" + config_path.write_bytes(canonical(config)) + environment = dict(os.environ) + provider_secret = "provider-secret-proof-" + nonce + environment["PROVIDER_SECRET_PROOF"] = provider_secret + service_process = subprocess.Popen( + [str(service), str(config_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + env=environment, + ) + wait_for_service(service_process, socket_root / "checker.sock") + envelope = { + "protocol_version": PROTOCOL, + "operation": "execute", + "request": request, + "grant": { + "grant_id": MATERIAL_GRANT_ID, + "binding_digest": manifest["binding_digest"], + }, + "model_grant": { + "grant_id": MODEL_GRANT_ID, + "binding_digest": request["request_digest"], + }, + } + with socket.socket(socket.AF_UNIX) as connection: + connection.settimeout(45) + connection.connect(str(socket_root / "checker.sock")) + write_frame(connection, canonical(envelope)) + response = json.loads(read_frame(connection, 96 * 1024)) + + proxy_thread.join(timeout=5) + if proxy_thread.is_alive() or "error" in proxy_result: + raise RuntimeError( + f"fake proxy exchange failed: {proxy_result.get('error')}" + ) + if ( + response["result"]["outcome"] != "completed" + or response["result"]["verdict"] != "passed" + ): + raise RuntimeError(f"checker execution failed: {response['result']}") + if response["isolation"]["isolation_mode"] != "docker-dev": + raise RuntimeError("missing Docker-dev isolation receipt") + + inspected = json.loads(inspection_path.read_text())[0] + checker_id = inspected["Id"] + mounts = {item["Destination"]: item for item in inspected["Mounts"]} + material_mount = mounts["/work/input"] + model_mount = mounts["/work/model.sock"] + if inspected["HostConfig"]["NetworkMode"] != "none": + raise RuntimeError("checker network was not disabled") + if inspected["HostConfig"]["ReadonlyRootfs"] is not True: + raise RuntimeError("checker root filesystem was writable") + if material_mount["RW"] or material_mount["Source"] != str(workspace): + raise RuntimeError("checker material mount was not exact and read-only") + model_source = Path(model_mount["Source"]) + if model_mount["RW"] or model_source.name != "model.sock": + raise RuntimeError("checker model socket mount was not read-only") + if ( + model_source.parent.parent != relay_root + or not model_source.parent.name.startswith(".model-") + ): + raise RuntimeError( + "checker received a non-invocation-local model socket" + ) + serialized_inspection = canonical(inspected).decode() + for forbidden in (str(proxy_address), MODEL_GRANT_ID, provider_secret): + if forbidden in serialized_inspection: + raise RuntimeError( + "private proxy material reached the checker container" + ) + if inspect_container(docker, checker_id) is not None: + raise RuntimeError("checker container was not cleaned") + if any(relay_root.iterdir()): + raise RuntimeError("invocation relay path was not cleaned") + print( + json.dumps( + { + "status": "PASS", + "runtime": "docker-dev/runc", + "network_mode": "none", + "rootfs_read_only": True, + "material_read_only": True, + "invocation_socket_read_only": True, + "proxy_request_closed": proxy_result["request"] + == { + "protocol_version": MODEL_PROTOCOL, + "grant_id": MODEL_GRANT_ID, + "request_digest": request["request_digest"], + "prompt": PROMPT, + }, + "reply": MODEL_REPLY["output"], + "checker_container_removed": True, + "relay_directory_empty": True, + "gvisor_qualified": False, + }, + sort_keys=True, + ) + ) + finally: + if service_process is not None: + if service_process.poll() is None: + service_process.terminate() + try: + service_process.wait(timeout=10) + except subprocess.TimeoutExpired: + service_process.kill() + service_process.wait(timeout=10) + service_process.stderr.close() + if registry_process is not None and registry_process.poll() is None: + registry_process.terminate() + try: + registry_process.wait(timeout=10) + except subprocess.TimeoutExpired: + registry_process.kill() + registry_process.wait(timeout=10) + if ( + checker_id is None + and inspection_path is not None + and inspection_path.exists() + ): + try: + checker_id = json.loads(inspection_path.read_text())[0]["Id"] + except (KeyError, IndexError, TypeError, ValueError): + checker_id = None + for identity in containers: + if inspect_container(docker, identity) is not None: + run(docker, "rm", "--force", identity) + for name in container_names: + if inspect_container(docker, name) is not None: + run(docker, "rm", "--force", name) + if ( + checker_id is not None + and inspect_container(docker, checker_id) is not None + ): + run(docker, "rm", "--force", checker_id) + for reference in (registry_tag, local_tag): + if reference is not None: + try: + subprocess.run( + [docker, "image", "rm", "--force", reference], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=30, + ) + except subprocess.TimeoutExpired: + pass + if created_image_id is not None: + try: + subprocess.run( + [docker, "image", "rm", "--force", created_image_id], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=30, + ) + except subprocess.TimeoutExpired: + pass + remaining = [ + identity + for identity in (*containers, *container_names) + if inspect_container(docker, identity) + ] + if ( + checker_id is not None + and inspect_container(docker, checker_id) is not None + ): + remaining.append(checker_id) + retained_images = [] + for reference in (registry_tag, local_tag, created_image_id): + if reference is None: + continue + result = subprocess.run( + [docker, "image", "inspect", reference], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=10, + ) + if result.returncode == 0: + retained_images.append(reference) + if remaining or retained_images: + raise RuntimeError( + "probe cleanup incomplete: " + f"containers={remaining}; images={retained_images}" + ) + + +if __name__ == "__main__": + main() diff --git a/mcp_server/contracts/admin_roles_list.json b/mcp_server/contracts/admin_roles_list.json index 8ea70e91b..b52840f32 100644 --- a/mcp_server/contracts/admin_roles_list.json +++ b/mcp_server/contracts/admin_roles_list.json @@ -1,5 +1,5 @@ { - "canonical_sha256": "17732f8c534e37651c1dfa36625e171f67aa482a6739570e17d3341b1cf44192", + "canonical_sha256": "c202da7ee0b8bbbeddf16471fcd0e82e29c75464bb65c8387780f5d9ff9bab7c", "components": { "schemas": { "AdminRole": { @@ -156,6 +156,15 @@ "checker.pre_submit.finalize", "checker.post_submit.execute", "checker.post_submit.finalize", + "checker.model_call.prepare", + "checker.model_call.dispatch", + "checker.model_call.recover", + "checker.model_call.observe", + "checker.model_call.complete", + "checker.model_call.grant", + "project.model_profile.manage", + "project.model_budget.manage", + "project.model_call.recovery_child.authorize", "outbox.dispatch", "actor.service.provision", "admin_role.read", @@ -430,7 +439,7 @@ "schema_version": 1, "source": { "action_id": "authorization.admin_role_definitions.read", - "backend_git_sha": "7326dc95e59f59fc9f6dee438857091e73c370aa", + "backend_git_sha": "3a1d2b53062c534d199f7d7087832b66d2c0b72a", "method": "GET", "openapi_version": "3.1.0", "path": "/api/v1/authorization/admin-role-definitions" diff --git a/mcp_server/contracts/authorization_context_get.json b/mcp_server/contracts/authorization_context_get.json index feb640d90..87ef52804 100644 --- a/mcp_server/contracts/authorization_context_get.json +++ b/mcp_server/contracts/authorization_context_get.json @@ -1,9 +1,9 @@ { - "canonical_sha256": "5972b939c5c83b6790b6025d18e08623199cb1ad288851d62f5ba34bedae7061", + "canonical_sha256": "49952b484c5a99b013f492d027744844e3c18cd91d2bf68ff8db07fad58e7f47", "components": { "schemas": { "ActionId": { - "type": "string", + "description": "Closed action identifiers reserved by approved owner chunks.", "enum": [ "task.submission.list", "submission.read", @@ -59,6 +59,15 @@ "checker.pre_submit.finalize", "checker.post_submit.execute", "checker.post_submit.finalize", + "checker.model_call.prepare", + "checker.model_call.dispatch", + "checker.model_call.recover", + "checker.model_call.observe", + "checker.model_call.complete", + "checker.model_call.grant", + "project.model_profile.provision", + "project.model_budget.configure", + "project.model_call.recovery_child.authorize", "outbox.dispatch", "actor.service.provision", "project.contributor_candidate.list", @@ -184,7 +193,7 @@ "contribution.policy.list" ], "title": "ActionId", - "description": "Closed action identifiers reserved by approved owner chunks." + "type": "string" }, "ActorAuthorizationContextResponse": { "properties": { @@ -568,7 +577,7 @@ "schema_version": 1, "source": { "action_id": "actor.authorization_context.read", - "backend_git_sha": "5f2e1c97f1de1145bb61717fbcd2b414adfb7d92", + "backend_git_sha": "3a1d2b53062c534d199f7d7087832b66d2c0b72a", "method": "GET", "openapi_version": "3.1.0", "path": "/api/v1/actors/me/authorization-context" diff --git a/mcp_server/contracts/permissions_list.json b/mcp_server/contracts/permissions_list.json index 7e751040b..54497e46c 100644 --- a/mcp_server/contracts/permissions_list.json +++ b/mcp_server/contracts/permissions_list.json @@ -402,7 +402,7 @@ "schema_version": 1, "source": { "action_id": "authorization.permission_catalogue.read", - "backend_git_sha": "7326dc95e59f59fc9f6dee438857091e73c370aa", + "backend_git_sha": "3a1d2b53062c534d199f7d7087832b66d2c0b72a", "method": "GET", "openapi_version": "3.1.0", "path": "/api/v1/authorization/permissions" diff --git a/pilots/terminal_systems/README.md b/pilots/terminal_systems/README.md index 3028ace55..44035f4a0 100644 --- a/pilots/terminal_systems/README.md +++ b/pilots/terminal_systems/README.md @@ -115,8 +115,9 @@ All 57 accumulated provider resources were deleted. The project-owned local rule tests and networkless container samples qualify its intake behavior. Its samples are structural fixtures, not completed authoring submissions. The eight POST checks and their model, isolated execution and authenticated -material dependencies remain unavailable. No registered image, approved policy, -activated guide or contribution outcome is claimed. Engineering +material dependencies remain unavailable. The PRE image is published by digest +and registered in the local rehearsal; no approved binding, activated guide or +contribution outcome is claimed. Engineering must implement the remaining eight POST checks and resolve their runtime/material gaps, register the exact images, and obtain corrected bindings and PM approval before activation and the contributor journey. diff --git a/pilots/terminal_systems/checkers/README.md b/pilots/terminal_systems/checkers/README.md index 8e66327a9..1dff3fade 100644 --- a/pilots/terminal_systems/checkers/README.md +++ b/pilots/terminal_systems/checkers/README.md @@ -47,21 +47,33 @@ packages, semantic review evidence, or a substitute for the real project journey The image build excludes samples and tests. Findings contain fixed messages and safe project-relative paths, never raw TOML, commands or exception traces. -After review, publish through the same command with `--publish --account-id` -and the intended AWS account. Its default destination is -`workstream-checker-pre-terminal-systems` in `eu-north-1`. Registration, runner -cache provisioning, corrected proposal approval and activation remain separate -operations. A local `docker-dev` sample pass is not hosted gVisor evidence. +Publish through the same command with `--publish --account-id` and the intended +AWS account. Its default destination is `workstream-checker-pre-terminal-systems` +in `eu-north-1`. The reviewed implementation was published on 2026-10-23 as: + +```text +094032936130.dkr.ecr.eu-north-1.amazonaws.com/workstream-checker-pre-terminal-systems@sha256:24d8d0ad46e1f10c85d8883ddd29bcc7708501e560df067cdd3fdab34785a83c +``` + +The publisher pulled that exact manifest back and passed all seven samples with +config image ID `sha256:85f32d02a7febd1b476810f5cb7cd0b6056a9b29c377f78e492b005b23a81aef`. +The local rehearsal registry entry is `01a1665b-892e-7a56-a511-6fd9be45a60a`, +created through the Operator CLI. These are image publication and registry facts, +not deployment or activation. Runner cache provisioning, corrected proposal +approval and activation remain separate operations. Local `docker-dev` samples +do not establish hosted gVisor isolation. ## POST prerequisites The six model specifications and two execution specifications are not yet implemented. The user has resumed the minimal budgeted model runtime needed by -this project. It must preserve digest-pinned project images, exact locked -configuration and Workstream-owned lifecycle decisions. Credentials stay outside -the sandbox. Project-budget reservations, provider-call identity and uncertain -delivery recovery must prevent duplicate paid calls and distinguish infrastructure -failure from contributor failure. +this project. The generic runtime and its Operator provisioning/recovery controls +are described in [the model runtime runbook](../../../docs/operations_checker_models.md). +They preserve digest-pinned project images, exact locked configuration and +Workstream-owned lifecycle decisions. Credentials stay outside the sandbox. +The project still needs its review images, approved model profile selectors and +the authorized US$10 lifetime budget before live calls. Offline runtime proofs +do not establish this project's activation or live provider operation. Execution additionally needs a generic isolated build/trial capability and approved offline material. Author-evidence review needs authenticated trial From 54e65231b764200ee5888629179da07d94dbeacf Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 14:10:27 +0100 Subject: [PATCH 005/115] docs: record checker model runtime QA review --- .commitrail/changes/checker-model-runtime.md | 79 ++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/.commitrail/changes/checker-model-runtime.md b/.commitrail/changes/checker-model-runtime.md index 3495ed748..90080b7a1 100644 --- a/.commitrail/changes/checker-model-runtime.md +++ b/.commitrail/changes/checker-model-runtime.md @@ -251,3 +251,82 @@ user approved a hard US$10 aggregate rehearsal cap; enforce it before live calls Review current main and open migrations before allocating a revision number. Required execution trials and authenticated prior-work context must still be supplied before final guide activation. + +## Independent QA, test-delta and CI review + +Review dimensions: `qa`, `test_delta`, and `ci_integrity`. Review target: +base and merge base `6fa9a9b557fa3ae22c16af67bca25dc868f67b38`, frozen head +`57b3619a22fb23995d663cb747a074e93af9225f`. The start snapshot and the +end-of-evidence snapshot both reported that exact head, `final_ready: true`, and +a clean worktree. This section is the only worktree mutation made after the end +snapshot. The final merge differs from runtime commit `63614e145` only in the +separate terminal-systems PRE change record and roadmap prose; this review did +not repeat the PRE review. + +Verdict: **PASS WITH LOW RISKS** for the three review dimensions. No runtime, +test-selection, or required-job defect was confirmed. Finding `QA-CI-001` is a +non-blocking reliability risk in one Rust unit test. Publication remains +separately blocked on Commitrail branch reconciliation: comparison with main +includes the PRE record, while comparison with the PRE head includes the newer +main record. That gate must not be weakened. + +### Finding + +- **QA-CI-001 — severity Low; confidence high; non-blocking — timing-sensitive + relay assertion.** Target `57b3619a2`; location + `external_checkers/workstream-checker-service/src/model_relay.rs:891` and + `:910`; failure pattern `FP-TIME-BOUNDARY-FLAKE`. The required exact-head + Rust workspace invocation failed once because + `distinct_invocations_and_frame_bounds_do_not_cross` did not observe + `first.finish().failed`. The test gives the relay only 100 ms to accept and + classify the oversized frame. The exact node passed immediately in + isolation, passed 30 further isolated repetitions, and the whole 44-test + workspace passed on rerun. This does not establish a relay runtime failure, + but a loaded runner can produce a false required-check failure. Use a + deterministic readiness handshake, or a materially larger deadline while + retaining a separate elapsed-time bound. Verification should repeat the + workspace under scheduler load and show the oversized-frame assertion still + reaches the intended branch. + +### Atomic traceability + +| Behavior atom | Production owner and changed boundary | Discriminating evidence | Result and custody | +| --- | --- | --- | --- | +| A published POST profile fixes provider, API, model snapshot, token ceiling, pricing and maximum reservation before spend. | `backend/app/modules/checkers/model_calls/profiles.py`, `backend/app/modules/checkers/model_calls/service.py:550`, `backend/app/adapters/checkers/model_provider.py:113` | Exact-head contract, provider-adapter, proxy-orchestration and AUTH selection; source inspection of stored background response creation and strict usage normalization. | Pass in the reviewer-run 76-test selection. This is controlled-provider contract evidence, not a live-provider claim. | +| One stable paid slot survives checker retry; ambiguous provider effects are not recreated; lifetime reserved plus settled spend cannot exceed the project ceiling. | `backend/app/modules/checkers/model_calls/service.py:683`, `:911`, `:1186`; migration `0059_checker_model_runtime.py` | Exact-head owned PostgreSQL/MinIO nodes `minio-retry-retains-paid-effect` and `test_concurrent_dispatch_reserves_one_lifetime_budget`. | Pass. Metadata binds schema `0059`, head `57b3619a2`, owned PG and MinIO namespaces, and successful cleanup. | +| Request and response evidence stays in private ART custody and completion consumes the retained paid effect. | `backend/app/adapters/checkers/model_proxy.py:142`, `:231`, `:276`; ART model-call evidence and binding modules | The exact MinIO composed node exercises real local PostgreSQL and MinIO with the controlled proxy/provider/image boundary; canonical ART, lineage, rollback, currentness and old-tail cases were also inspected in the supplied artifacts. | Exact composed boundary passes. The broader supplied artifacts were produced on the same runtime tree before commit/final-doc merge and are producer-attested rather than self-authenticating exact-head proof. | +| Operator recovery creates a bounded authorized child only after proven no-effect or fully settled failure. | `backend/app/modules/checkers/model_calls/service.py:607`, `:1045`, `:1102`; checker retry source | Exact-head composed `operator-recovers-no-effect`; supplied six-case composed artifact additionally covers settled-failure recovery. | No-effect recovery passes at exact head. Settled-failure coverage is inspected producer-attested evidence and is not promoted to exact-head execution. | +| Prepared checker-output AUTH accepts its JSON packet while still rejecting string-coerced integers. | `backend/app/modules/authorization/domain/checker_outputs.py:213`; `backend/tests/authorization/checker_models/test_contracts.py:141` | Exact-head positive tests plus an in-memory old strict-Python-decoder mutant. | Pass; both WRITE and BIND tests fail at the intended validation assertion under the mutant, before no unrelated setup. | +| A checker image receives one invocation-local relay socket, with no network, a read-only root and a closed response contract. | `external_checkers/workstream-checker-service/src/model_relay.rs:1`, service container arguments, `docker-compose.agent-loop.yml:59` and `:103` | Exact-head Python transport tests, exact-head Rust workspace rerun, exact Compose render, and inspected real-Docker relay proof. | Controlled boundaries pass, subject to `QA-CI-001`. The Docker artifact proves Docker only; it is not gVisor qualification. | +| CLI and MCP surfaces preserve the public model-operation and authorization vocabulary. | `cli/internal/api/checker_models.go`, `cli/internal/api/checker_operations.go`, MCP contracts and access-read tests | Inspected CLI operation artifact (including Go build/vet) and corrected MCP contract artifact, plus actual-backend permission/role evidence supplied by the producer. | Pass within artifact custody. GitHub authentication was invalid, so no hosted check result was available to independently bind these runs to the PR. | +| The changed cross-cutting surface selects all required backend, external checker, MinIO, CLI, MCP and semantic-lane jobs. | `backend/scripts/select_ci_scope.py:121`, `backend/scripts/test_lane_catalogue.py:485`, `.github/workflows/backend.yml` | Reviewer executed the selector on the exact base/head pair; it returned `unknown_or_cross_cutting_path` with every required boolean true and the full lane matrix. Catalogue ownership and the final required-job aggregator were inspected. | Pass. No hosted workflow was run during this review. | + +### Evidence executed by this reviewer + +- `/dev/shm/model-qa-exact-head-minio-v5.log` and `.json`: three selected + tests passed against a fresh owned PostgreSQL database and MinIO namespace; + tree, migration head, provisioning, probe and cleanup fields are present. +- `/dev/shm/model-qa-exact-head-pure.log`: 76 selected contract, runtime, + proxy, provider, transport and authorization tests passed. +- `/dev/shm/model-qa-parser-mutant-exact.log`: the old decoder produced both + expected failures, killing the targeted regression mutant. +- `/dev/shm/model-qa-exact-head-rust.log`: the first workspace run exposed + `QA-CI-001`; `/dev/shm/model-qa-exact-head-rust-retry.log` passed the isolated + node, 30 additional repetitions passed, and + `/dev/shm/model-qa-exact-head-rust-rerun.log` passed the workspace rerun. +- Exact-head Compose rendering passed the proxy/socket/network/read-only mount + assertions. Exact base/head CI selection produced + `/dev/shm/model-qa-ci-scope-*.out` with full scope. +- `/dev/shm/model-qa-end-target.json` records the clean final evidence + snapshot before this required review-record write. + +### Inspected evidence and remaining limits + +The review also inspected the supplied composed, ledger/recovery, signed-public, +canonical ART, old-tail, retained-upgrade, provider/proxy, output-contract, +CLI, MCP, Rust and real-Docker artifacts. The original old-tail artifact was +excluded because its database was cleaned too early; only `tail-v2` was used. +Test names and counts were not treated as runtime proof: assertions, setup +completion, metadata custody and owner code were inspected. No live provider +call, hosted CI run, gVisor run, or complete real-project execution occurred, +so this verdict makes none of those claims. From c17d347a7b6d021eaada052bd55937ef9d3a8e53 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 14:26:41 +0100 Subject: [PATCH 006/115] test(checkers): await model relay frame rejection --- .../src/model_relay.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/external_checkers/workstream-checker-service/src/model_relay.rs b/external_checkers/workstream-checker-service/src/model_relay.rs index a3d9f296d..40eabdf03 100644 --- a/external_checkers/workstream-checker-service/src/model_relay.rs +++ b/external_checkers/workstream-checker-service/src/model_relay.rs @@ -882,14 +882,14 @@ mod tests { grant_id: format!("model_{}", "1".repeat(32)), binding_digest: digest.clone(), }; - let first = InvocationRelay::start( + let mut first = InvocationRelay::start( &root, &proxy_path, &grant, &digest, uid, gid, - Instant::now() + Duration::from_millis(100), + Instant::now() + Duration::from_secs(5), ) .unwrap(); let second = InvocationRelay::start( @@ -899,7 +899,7 @@ mod tests { &digest, uid, gid, - Instant::now() + Duration::from_millis(100), + Instant::now() + Duration::from_secs(5), ) .unwrap(); assert_ne!(first.socket_path(), second.socket_path()); @@ -907,7 +907,17 @@ mod tests { attacker .write_all(&((MODEL_FRAME_MAXIMUM + 1) as u32).to_be_bytes()) .unwrap(); - assert!(first.finish().failed); + // Wait for the relay to close its accepted peer before inspecting the + // worker result. Calling `finish` immediately would cancel a thread + // that had not necessarily accepted the queued connection yet. + attacker + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut closed = [0_u8; 1]; + assert_eq!(attacker.read(&mut closed).unwrap(), 0); + let result = first.worker.take().unwrap().join().unwrap(); + assert_eq!(result, Err("model relay frame unbounded".into())); + assert!(!first.finish().cleanup_unsettled); assert!(!second.finish().cleanup_unsettled); fs::remove_file(proxy_path).unwrap(); fs::remove_dir(root).unwrap(); From 4a754d06263bbe6785fc06f6f09fcdcb8d82a6e6 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 14:30:21 +0100 Subject: [PATCH 007/115] docs: expand model runtime to shared usage --- .commitrail/changes/checker-model-runtime.md | 103 +++++++++++++------ 1 file changed, 69 insertions(+), 34 deletions(-) diff --git a/.commitrail/changes/checker-model-runtime.md b/.commitrail/changes/checker-model-runtime.md index 90080b7a1..61a53a3b0 100644 --- a/.commitrail/changes/checker-model-runtime.md +++ b/.commitrail/changes/checker-model-runtime.md @@ -1,33 +1,44 @@ -# Project-image model calls with bounded spend and durable recovery +# Shared budgeted model access for setup and POST checker agents - Initiative: None - Durable disposition: Planned -- Intended merge outcome: Give policy-bound external POST images controlled model access, with project spend custody, immutable evidence and recovery that cannot silently repeat a paid call. +- Intended merge outcome: Give setup inference and policy-bound POST agents one + project-funded model-use boundary with authentic workload lineage, immutable + evidence and recovery that cannot silently repeat a paid effect. ## Intent -The user explicitly resumed the minimum PILOT-05 runtime needed by the real -Terminal Systems project. Six proposed POST specifications require model review. -The image owns the rubric, prompt and interpretation; Workstream owns authority, -spend and lifecycle. No project-specific rule belongs in the shared Rust runner. +The user resumed PILOT-05 for the real Terminal Systems project and then expanded +its intended outcome to the existing setup inference adapters. POST images own +their rubrics, bounded agent loops and interpretation; setup retains its existing +strict draft/review/correction SDK loops. Workstream owns one model-use authority, +project lifetime budget, provider effect and evidence boundary across both. No +project rule or generic agent framework belongs in the shared Rust runner. ## Current behavior -`selected_post_submit_execution.py` executes locked images over an ART callback. -The Rust service exposes only read-only verified submission material in a -networkless container. There is no model credential, project spend ledger or -provider-call recovery. `execution_coordination.py` creates a successor checker -generation on Operator retry; using that generation as a paid-call key would -permit duplicate charges. ART already defines generated checker output custody -ports, but their production reservation path needs inspection before reuse. +The unmerged branch already proves a checker-only OpenAI Responses call, project +budget, protected request/response evidence, provider-effect recovery and a +single-use invocation socket. Current main separately runs OpenAI Agents SDK and +OmniCoreAgent setup inference directly through their pinned native transports. +Neither side is the shared boundary: the checker profile is registry-bound and +one-call, while setup has no durable usage ledger. Generalize the unmerged schema +directly after main's `0059_neutral_guide_runtime`; do not preserve checker-only +development names or introduce a second meter. ## Bounded change ### Allowed -- CHECKERS model-call contracts, models, repository/service operations and - selected POST integration; existing AUTH and TASK ports for fresh execution - authority/currentness and Operator provisioning. +- A provider-neutral MODEL-USAGE owner for project profiles, one project lifetime + account, reservations, settlement, paid effects and protected evidence. +- Strong owner-specific setup and CHECKERS subject contracts. Setup binds the + exact compilation attempt, setup generation and snapshotted runtime hash; POST + binds exact Submission/requirement/registry/configuration and current run/lease. +- Existing pinned OpenAI Agents SDK and OmniCoreAgent request boundaries, without + replacing either agent loop or forwarding arbitrary HTTP. +- CHECKERS selected POST integration and existing AUTH, PROJECTS and TASK ports + for fresh owner currentness and Operator provisioning. - Existing ART generated checker-output custody to retain bounded private request/response evidence, with matching authority and read concealment. - Typed provider capability under `backend/app/interfaces/`, concrete adapter @@ -45,23 +56,28 @@ ports, but their production reservation path needs inspection before reuse. ### Not allowed No provider credential or global proxy socket in project images; no contributor -billing, setup-agent billing, alternate checker implementation kind, project -rules in Rust, unrestricted provider/model selection, automatic paid retries -after ambiguous delivery, task/revision spending on infrastructure failures, -or live model spend beyond the user-confirmed US$10 total rehearsal cap. No merge or -hosted deployment is authorized. Execution trials and prior-revision material -remain separately necessary before the entire project can activate. +or project-owner billing, alternate checker implementation kind, project rules +in Rust, generic agent framework, arbitrary forwarding proxy, unrestricted +provider/model selection, automatic paid retry after ambiguous delivery, or +task/revision spending on infrastructure failures. Historical setup spend lacks +authenticated pricing/usage facts and must not be guessed as zero or backfilled; +paid work stays disabled until an Operator provisions an externally reconciled +remaining allowance within the already-approved US$10 total rehearsal ceiling. +No live paid call, merge or hosted deployment is authorized here. ## Design and decisions -An immutable project model profile binds the exact registry entry and digest, provider/API/model -snapshot, generation parameters, token/deadline ceilings, one allowed call slot -and immutable integer pricing. Reserve the complete selector triple +An immutable project model-use profile binds provider/API/model snapshot, +generation parameters, per-call and whole-run dispatch/token/byte/deadline +ceilings, supported tool/request shape and immutable integer pricing. The common +profile is not registry-owned; the POST owner separately binds it to an exact +registry/image/configuration while setup snapshots the same selector into its +attempt runtime configuration. Reserve the complete selector triple `model_profile_id`, `model_profile_version`, `model_profile_digest` in the flat PM-approved image configuration. A typed exact profile reader checks project, -registry and profile identity during policy compilation, readiness/activation and -runtime. Partial selectors fail; the existing POST policy hash retains this -custody without a new checker kind or TASK lineage column. +provider/API/model and profile identity during policy compilation, setup request, +readiness/activation and runtime. Partial selectors fail; existing owner hashes +retain custody without a new checker kind or TASK lineage column. The user authorized US$10 total for the Terminal Systems rehearsal; configure 10,000,000,000 USD nanodollars and preserve all reservations and settled spend across retries and configuration versions. No live call may precede enforcement. @@ -69,12 +85,31 @@ An Operator sets a finite project budget; profiles do not allow images to change that budget or choose a different model. Monetary amounts use integer USD nanodollars with explicit immutable pricing provenance. -Trusted execution creates a callback-scoped model grant bound to the current -checker lease, exact Submission, requirement, registry/configuration and profile. -Rust exposes only an invocation-local socket. A bounded image request supplies -prompt material; the trusted proxy supplies identity and fixed provider settings. -The global endpoint and opaque grant never enter image input or logs. The backend -verifies a completed call receipt before accepting a model-enabled image verdict. +Each owner allocates a finite phase/call slot before provider I/O; transport +arrival order and in-memory counters cannot mint durable identities. POST grants +bind current execution and exact Submission/requirement/registry/configuration. +Setup grants bind the exact compilation attempt/generation/runtime hash without a +fabricated CheckerRun. Rust exposes only invocation-local capabilities. Native +setup wrappers intercept each real pinned-SDK request; project images receive a +restricted model session. Credentials, global endpoints and opaque backend grants +never enter image input or logs. + +Whole-run reservation is sized for every possible actual provider dispatch, +including correction and supported retries. Hidden SDK retries are disabled or +metered individually; ambiguity prohibits the next dispatch. Restart replays +cached exact provider responses only when canonical request hashes match, so SDK +state and tool-call identities reconstruct without another paid create. Known +provider IDs may be retrieved; unknown effects retain reservation and cannot be +recreated. New children remain limited to proven no-effect or fully settled +eligible failures. + +The common effect row has exactly one SQL-enforced strong owner binding. Checker +lineage retains composite submission/registry/configuration custody; setup lineage +uses composite compilation-attempt/project/setup-generation/runtime-hash custody. +No owner-kind/owner-ID pair or JSON-only lineage is sufficient. AUTH plus the +affected PROJECTS/TASK owner locks precede MODEL locks; narrow late completion can +finish an already-paid exact effect without re-entering those owners, while a new +dispatch always requires fresh owner currentness first. The paid slot identity is `(project, assignment, submission, submission_version, requirement, registry_digest, configuration_hash, profile_version, slot_index)`. From a470b2b229379ffb42f2cf75f276ee94ba6be703 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 17:21:42 +0100 Subject: [PATCH 008/115] feat(model-usage): share budget custody across setup and checkers --- .ci/module-boundaries/registry.v1.json | 1 + backend/alembic/env.py | 2 +- ...r_model_runtime.py => 0060_model_usage.py} | 956 +++++++++--- backend/app/adapters/auth/__init__.py | 2 +- backend/app/adapters/checkers/__init__.py | 44 +- .../app/adapters/checkers/model_execution.py | 2 +- backend/app/adapters/checkers/model_proxy.py | 2 +- .../app/adapters/checkers/model_runtime.py | 2 +- backend/app/adapters/model_usage/__init__.py | 61 + backend/app/api/deps/checker_models.py | 2 +- backend/app/db/models.py | 17 +- .../artifacts/checker_model_evidence.py | 2 +- .../app/modules/artifacts/checker_outputs.py | 2 +- backend/app/modules/artifacts/models.py | 4 +- .../modules/authorization/admin_schemas.py | 2 +- .../app/modules/authorization/catalogue.py | 1390 +++++++++++++---- .../authorization/domain/action_groups.py | 2 +- .../authorization/domain/audit_targets.py | 2 +- .../{checker_models.py => model_usage.py} | 142 +- .../domain/prepared_checker_runtime.py | 5 +- .../authorization/domain/prepared_service.py | 158 +- backend/app/modules/authorization/kernel.py | 2 +- ...zation.py => model_usage_authorization.py} | 122 +- .../authorization/prepared_admin_authority.py | 2 +- backend/app/modules/authorization/runtime.py | 2 +- backend/app/modules/checkers/api/execution.py | 2 +- .../modules/checkers/api/external_policy.py | 2 +- .../app/modules/checkers/api/model_grants.py | 34 +- .../modules/checkers/api/model_profiles.py | 6 +- .../modules/checkers/api/output_custody.py | 2 +- backend/app/modules/checkers/checker_retry.py | 427 +++-- .../modules/checkers/model_calls/__init__.py | 5 - .../checkers/model_calls/repository.py | 216 --- .../custody.py => model_usage_custody.py} | 100 +- backend/app/modules/checkers/router.py | 2 +- backend/app/modules/model_usage/__init__.py | 1 + .../api/model_calls.py => model_usage/api.py} | 400 ++++- .../model_calls => model_usage}/grants.py | 28 +- .../model_calls => model_usage}/models.py | 405 ++++- .../app/modules/model_usage/owner_ports.py | 163 ++ .../model_calls => model_usage}/profiles.py | 33 +- backend/app/modules/model_usage/repository.py | 383 +++++ .../model_calls => model_usage}/service.py | 299 ++-- backend/app/modules/model_usage/setup.py | 556 +++++++ .../projects/guide_activation/service.py | 2 +- .../projects/guide_compilation/model_usage.py | 38 + .../projects/guide_compilation/models.py | 10 + .../guide_compilation/proposal_approval.py | 2 +- .../guide_compilation/proposal_service.py | 2 +- backend/scripts/module_boundaries.py | 2 +- .../architecture/test_module_boundaries.py | 96 +- .../test_admin_reads_postgresql.py | 2 +- .../test_grant_reads_postgresql.py | 2 +- .../tests/authorization/catalogue_fixtures.py | 24 + .../checker_models/test_contracts.py | 6 +- backend/tests/authorization/test_catalogue.py | 66 +- .../tests/checker_output_admission_helpers.py | 142 +- .../tests/checker_output_custody_helpers.py | 20 +- .../tests/checkers/external/test_policy.py | 14 +- .../test_composed_execution.py | 12 +- .../test_model_call_contract.py | 88 +- ...test_model_call_transactions_postgresql.py | 885 +++++++++-- ...test_model_invocation_grants_postgresql.py | 24 +- .../test_model_runtime.py | 2 +- .../test_proxy_orchestration.py | 8 +- .../checkers_model_calls/test_public_api.py | 52 +- backend/tests/conftest.py | 32 +- backend/tests/test_checker_output_custody.py | 6 +- docs/architecture_data_model.md | 14 +- .../repository_architecture_boundaries.md | 6 +- 70 files changed, 5833 insertions(+), 1716 deletions(-) rename backend/alembic/versions/{0060_checker_model_runtime.py => 0060_model_usage.py} (65%) create mode 100644 backend/app/adapters/model_usage/__init__.py rename backend/app/modules/authorization/domain/{checker_models.py => model_usage.py} (70%) rename backend/app/modules/authorization/{checker_model_authorization.py => model_usage_authorization.py} (74%) delete mode 100644 backend/app/modules/checkers/model_calls/__init__.py delete mode 100644 backend/app/modules/checkers/model_calls/repository.py rename backend/app/modules/checkers/{model_calls/custody.py => model_usage_custody.py} (67%) create mode 100644 backend/app/modules/model_usage/__init__.py rename backend/app/modules/{checkers/api/model_calls.py => model_usage/api.py} (61%) rename backend/app/modules/{checkers/model_calls => model_usage}/grants.py (88%) rename backend/app/modules/{checkers/model_calls => model_usage}/models.py (55%) create mode 100644 backend/app/modules/model_usage/owner_ports.py rename backend/app/modules/{checkers/model_calls => model_usage}/profiles.py (74%) create mode 100644 backend/app/modules/model_usage/repository.py rename backend/app/modules/{checkers/model_calls => model_usage}/service.py (85%) create mode 100644 backend/app/modules/model_usage/setup.py create mode 100644 backend/app/modules/projects/guide_compilation/model_usage.py diff --git a/.ci/module-boundaries/registry.v1.json b/.ci/module-boundaries/registry.v1.json index 67468eac7..1ecb74479 100644 --- a/.ci/module-boundaries/registry.v1.json +++ b/.ci/module-boundaries/registry.v1.json @@ -7,6 +7,7 @@ "tasks", "artifacts", "checkers", + "model_usage", "reviews", "contributions", "compensation" diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 5a7cc233f..82cc3a051 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -21,7 +21,7 @@ target_metadata = Base.metadata _BASELINE_REVISION = "0001_uuid7_v01" -_CURRENT_HEAD_REVISION = "0060_checker_model_runtime" +_CURRENT_HEAD_REVISION = "0060_model_usage" _RECREATE_GUIDANCE = ( "Workstream v0.1 requires a fresh database; recreate this database before " "running the 0001_uuid7_v01 migration" diff --git a/backend/alembic/versions/0060_checker_model_runtime.py b/backend/alembic/versions/0060_model_usage.py similarity index 65% rename from backend/alembic/versions/0060_checker_model_runtime.py rename to backend/alembic/versions/0060_model_usage.py index b9fec4aef..55e2ef935 100644 --- a/backend/alembic/versions/0060_checker_model_runtime.py +++ b/backend/alembic/versions/0060_model_usage.py @@ -1,17 +1,17 @@ -"""Add guarded project-budgeted model-call custody for external checkers.""" +"""Add shared project-budgeted model-use custody for setup and checkers.""" from alembic import op from sqlalchemy import text -revision = "0060_checker_model_runtime" +revision = "0060_model_usage" down_revision = "0059_neutral_guide_runtime" branch_labels = None depends_on = None def upgrade() -> None: - """Create the CHECKERS ledger before root-owned ART and AUTH extensions.""" + """Create shared MODEL-USAGE owners before ART and AUTH extensions.""" op.execute("SET LOCAL search_path=pg_catalog,public,pg_temp") for statement in _TABLES: op.execute(statement) @@ -71,7 +71,7 @@ def _upgrade_invocation_grants() -> None: FOREIGN KEY(authorization_evidence_id) REFERENCES public.audit_events(id) ON DELETE RESTRICT, CONSTRAINT fk_checker_model_invocation_grants_attempt FOREIGN KEY(selected_attempt_id) - REFERENCES public.checker_model_call_attempts(id) ON DELETE RESTRICT + REFERENCES public.model_usage_effects(id) ON DELETE RESTRICT ) """ ) @@ -92,9 +92,9 @@ def _upgrade_invocation_grants() -> None: DECLARE submission_task uuid; DECLARE run public.checker_runs%ROWTYPE; DECLARE registry public.external_checker_registry_entries%ROWTYPE; -DECLARE profile public.checker_model_profiles%ROWTYPE; -DECLARE attempt public.checker_model_call_attempts%ROWTYPE; -DECLARE slot public.checker_model_call_slots%ROWTYPE; +DECLARE profile public.model_usage_profiles%ROWTYPE; +DECLARE attempt public.model_usage_effects%ROWTYPE; +DECLARE slot public.checker_model_usage_slots%ROWTYPE; DECLARE binding_matches integer; BEGIN IF TG_OP='DELETE' THEN @@ -115,10 +115,10 @@ def _upgrade_invocation_grants() -> None: RAISE EXCEPTION 'checker model invocation grant expired' USING ERRCODE='23514'; END IF; - SELECT value.* INTO attempt FROM public.checker_model_call_attempts value + SELECT value.* INTO attempt FROM public.model_usage_effects value WHERE value.id=NEW.selected_attempt_id FOR SHARE; - SELECT value.* INTO slot FROM public.checker_model_call_slots value - WHERE value.id=attempt.slot_id FOR SHARE; + SELECT value.* INTO slot FROM public.checker_model_usage_slots value + WHERE value.id=attempt.checker_slot_id FOR SHARE; IF attempt.id IS NULL OR slot.id IS NULL OR attempt.request_sha256 IS DISTINCT FROM NEW.selected_request_sha256 OR slot.project_id IS DISTINCT FROM NEW.project_id OR @@ -231,7 +231,7 @@ def _upgrade_invocation_grants() -> None: WHERE id=NEW.checker_run_id FOR SHARE; SELECT * INTO registry FROM public.external_checker_registry_entries WHERE id=NEW.registry_entry_id FOR SHARE; - SELECT * INTO profile FROM public.checker_model_profiles + SELECT * INTO profile FROM public.model_usage_profiles WHERE id=(NEW.paid_slot->>'profile_id')::uuid AND profile_version=(NEW.paid_slot->>'profile_version')::integer AND profile_digest=NEW.paid_slot->>'profile_digest' FOR SHARE; @@ -269,10 +269,7 @@ def _upgrade_invocation_grants() -> None: run.status IS DISTINCT FROM 'running' OR run.phase IS DISTINCT FROM 'post_submission' OR registry.id IS NULL OR registry.phase IS DISTINCT FROM 'post_submit' OR registry.entry_digest IS DISTINCT FROM NEW.paid_slot->>'registry_entry_digest' OR - profile.id IS NULL OR profile.project_id IS DISTINCT FROM NEW.project_id OR - profile.registry_entry_id IS DISTINCT FROM NEW.registry_entry_id OR - profile.registry_entry_digest IS DISTINCT FROM - NEW.paid_slot->>'registry_entry_digest' THEN + profile.id IS NULL OR profile.project_id IS DISTINCT FROM NEW.project_id THEN RAISE EXCEPTION 'checker model invocation grant execution ancestry is invalid' USING ERRCODE='23514'; END IF; @@ -317,7 +314,7 @@ def _upgrade_invocation_grants() -> None: r""" CREATE TRIGGER checker_model_invocation_grants_no_truncate BEFORE TRUNCATE ON public.checker_model_invocation_grants - FOR EACH STATEMENT EXECUTE FUNCTION public.guard_checker_model_no_truncate() + FOR EACH STATEMENT EXECUTE FUNCTION public.guard_model_usage_no_truncate() """ ) @@ -326,7 +323,7 @@ def _upgrade_art_custody() -> None: """Extend verified ART ancestry to call-stable evidence, preserving retained rows.""" op.execute( r""" -ALTER TABLE artifact_put_attempts ADD COLUMN model_call_attempt_id uuid REFERENCES checker_model_call_attempts(id) ON DELETE RESTRICT +ALTER TABLE artifact_put_attempts ADD COLUMN model_call_attempt_id uuid REFERENCES model_usage_effects(id) ON DELETE RESTRICT """ ) op.execute( @@ -336,7 +333,7 @@ def _upgrade_art_custody() -> None: ) op.execute( r""" -ALTER TABLE artifact_operation_receipts ADD COLUMN model_call_attempt_id uuid REFERENCES checker_model_call_attempts(id) ON DELETE RESTRICT +ALTER TABLE artifact_operation_receipts ADD COLUMN model_call_attempt_id uuid REFERENCES model_usage_effects(id) ON DELETE RESTRICT """ ) op.execute( @@ -726,14 +723,15 @@ def _upgrade_art_custody() -> None: r""" CREATE FUNCTION public.guard_checker_model_artifact_ancestry() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ -DECLARE effect checker_model_call_attempts%ROWTYPE; - slot checker_model_call_slots%ROWTYPE; - profile checker_model_profiles%ROWTYPE; +DECLARE effect model_usage_effects%ROWTYPE; + slot checker_model_usage_slots%ROWTYPE; + profile model_usage_profiles%ROWTYPE; BEGIN IF NEW.model_call_attempt_id IS NULL THEN RETURN NEW; END IF; - SELECT * INTO STRICT effect FROM checker_model_call_attempts WHERE id=NEW.model_call_attempt_id; - SELECT * INTO STRICT slot FROM checker_model_call_slots WHERE id=effect.slot_id; - SELECT * INTO STRICT profile FROM checker_model_profiles + SELECT * INTO STRICT effect FROM model_usage_effects WHERE id=NEW.model_call_attempt_id; + SELECT * INTO STRICT slot FROM checker_model_usage_slots + WHERE id=effect.checker_slot_id; + SELECT * INTO STRICT profile FROM model_usage_profiles WHERE id=slot.profile_id AND profile_version=slot.profile_version AND profile_digest=slot.profile_digest; IF ROW(NEW.project_id,NEW.task_id,NEW.submission_id,NEW.submission_version) @@ -790,6 +788,22 @@ def _upgrade_authorization_vocabulary() -> None: ("checker.model_call.observe", "checker.model_call.observe"), ("checker.model_call.complete", "checker.model_call.complete"), ("checker.model_call.grant", "checker.model_call.grant"), + ( + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.prepare", + ), + ( + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.dispatch", + ), + ( + "project.guide_compilation.model.observe", + "project.guide_compilation.model.observe", + ), + ( + "project.guide_compilation.model.complete", + "project.guide_compilation.model.complete", + ), ("project.model_profile.provision", "project.model_profile.manage"), ("project.model_budget.configure", "project.model_budget.manage"), ( @@ -847,13 +861,11 @@ def downgrade() -> None: _TABLES = ( r""" -CREATE TABLE public.checker_model_profiles ( +CREATE TABLE public.model_usage_profiles ( id uuid NOT NULL, project_id uuid NOT NULL, profile_version integer NOT NULL, profile_digest varchar(71) NOT NULL, - registry_entry_id uuid NOT NULL, - registry_entry_digest varchar(71) NOT NULL, provider varchar(160) NOT NULL, api varchar(160) NOT NULL, model_snapshot varchar(160) NOT NULL, @@ -863,8 +875,15 @@ def downgrade() -> None: maximum_response_bytes integer NOT NULL, maximum_input_tokens integer NOT NULL, maximum_output_tokens integer NOT NULL, + maximum_calls integer NOT NULL, + maximum_total_request_bytes integer NOT NULL, + maximum_total_response_bytes integer NOT NULL, + maximum_total_input_tokens integer NOT NULL, + maximum_total_output_tokens integer NOT NULL, + allowed_tools jsonb NOT NULL, + maximum_tool_calls integer NOT NULL, + maximum_tool_output_bytes integer NOT NULL, deadline_ms integer NOT NULL, - call_slots integer NOT NULL, pricing_policy_id uuid NOT NULL, pricing_policy_version integer NOT NULL, pricing_policy_digest varchar(71) NOT NULL, @@ -875,113 +894,176 @@ def downgrade() -> None: actor_profile_id uuid NOT NULL, authorization_evidence_id uuid NOT NULL, created_at timestamptz DEFAULT clock_timestamp() NOT NULL, - CONSTRAINT pk_checker_model_profiles PRIMARY KEY(id), - CONSTRAINT ck_checker_model_profiles_id_uuid7 CHECK ( + CONSTRAINT pk_model_usage_profiles PRIMARY KEY(id), + CONSTRAINT ck_model_usage_profiles_id_uuid7 CHECK ( (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), - CONSTRAINT ck_checker_model_profiles_digests CHECK ( + CONSTRAINT ck_model_usage_profiles_digests CHECK ( profile_digest ~ '^sha256:[0-9a-f]{64}$' AND - registry_entry_digest ~ '^sha256:[0-9a-f]{64}$' AND pricing_policy_digest ~ '^sha256:[0-9a-f]{64}$'), - CONSTRAINT ck_checker_model_profiles_identifiers CHECK ( - provider='openai_responses' AND api='responses.v1' AND + CONSTRAINT ck_model_usage_profiles_identifiers CHECK ( + provider ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$' AND + api ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$' AND model_snapshot ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$'), - CONSTRAINT ck_checker_model_profiles_settings CHECK ( + CONSTRAINT ck_model_usage_profiles_settings CHECK ( generation_settings = jsonb_build_object( 'reasoning_effort',generation_settings->>'reasoning_effort', 'text_verbosity',generation_settings->>'text_verbosity') AND generation_settings->>'reasoning_effort' IN ('none','minimal','low','medium','high','xhigh','max') AND generation_settings->>'text_verbosity' IN ('low','medium','high')), - CONSTRAINT ck_checker_model_profiles_bounds CHECK ( - profile_version>0 AND pricing_policy_version>0 AND call_slots=1 AND + CONSTRAINT ck_model_usage_profiles_bounds CHECK ( + profile_version>0 AND pricing_policy_version>0 AND maximum_calls BETWEEN 1 AND 100 AND maximum_request_bytes BETWEEN 1 AND 1048576 AND maximum_output_bytes BETWEEN 1 AND 1048576 AND maximum_response_bytes BETWEEN 1 AND 1048576 AND maximum_response_bytes >= 6 * maximum_output_bytes + 4096 AND maximum_input_tokens BETWEEN 1 AND 10000000 AND maximum_output_tokens BETWEEN 1 AND 10000000 AND + maximum_total_request_bytes BETWEEN maximum_request_bytes AND 104857600 AND + maximum_total_response_bytes BETWEEN maximum_response_bytes AND 104857600 AND + maximum_total_input_tokens BETWEEN maximum_input_tokens AND 100000000 AND + maximum_total_output_tokens BETWEEN maximum_output_tokens AND 100000000 AND + maximum_total_request_bytes<=maximum_request_bytes*maximum_calls AND + maximum_total_response_bytes<=maximum_response_bytes*maximum_calls AND + maximum_total_input_tokens<=maximum_input_tokens*maximum_calls AND + maximum_total_output_tokens<=maximum_output_tokens*maximum_calls AND + maximum_tool_calls BETWEEN 0 AND 1000 AND + maximum_tool_output_bytes BETWEEN 0 AND 104857600 AND + jsonb_typeof(allowed_tools)='array' AND + ((jsonb_array_length(allowed_tools)=0 AND maximum_tool_calls=0 AND + maximum_tool_output_bytes=0) OR + (jsonb_array_length(allowed_tools)>0 AND maximum_tool_calls>0 AND + maximum_tool_output_bytes>0)) AND deadline_ms BETWEEN 100 AND 3600000 AND uncached_input_rate>=0 AND cached_input_rate>=0 AND output_rate>=0 AND greatest(uncached_input_rate,cached_input_rate,output_rate)>0 AND - maximum_input_tokens::numeric * + maximum_total_input_tokens::numeric * greatest(uncached_input_rate,cached_input_rate)::numeric + - maximum_output_tokens::numeric * output_rate::numeric BETWEEN 1 AND 9000000000000000000), - CONSTRAINT uq_checker_model_profiles_version UNIQUE(project_id,profile_version), - CONSTRAINT uq_checker_model_profiles_identity UNIQUE(id,profile_version,profile_digest), - CONSTRAINT uq_checker_model_profiles_digest UNIQUE(profile_digest), - CONSTRAINT uq_checker_model_profiles_operation UNIQUE(operation_id), - CONSTRAINT uq_checker_model_profiles_authority UNIQUE(authorization_evidence_id), - CONSTRAINT fk_checker_model_profiles_project FOREIGN KEY(project_id) + maximum_total_output_tokens::numeric * output_rate::numeric BETWEEN 1 AND 9000000000000000000), + CONSTRAINT uq_model_usage_profiles_version UNIQUE(project_id,profile_version), + CONSTRAINT uq_model_usage_profiles_identity UNIQUE(id,profile_version,profile_digest), + CONSTRAINT uq_model_usage_profiles_digest UNIQUE(profile_digest), + CONSTRAINT uq_model_usage_profiles_operation UNIQUE(operation_id), + CONSTRAINT uq_model_usage_profiles_authority UNIQUE(authorization_evidence_id), + CONSTRAINT fk_model_usage_profiles_project FOREIGN KEY(project_id) REFERENCES public.projects(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_profiles_registry FOREIGN KEY(registry_entry_id) - REFERENCES public.external_checker_registry_entries(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_profiles_actor FOREIGN KEY(actor_profile_id) + CONSTRAINT fk_model_usage_profiles_actor FOREIGN KEY(actor_profile_id) REFERENCES public.actor_profiles(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_profiles_authority FOREIGN KEY(authorization_evidence_id) + CONSTRAINT fk_model_usage_profiles_authority FOREIGN KEY(authorization_evidence_id) REFERENCES public.audit_events(id) ON DELETE RESTRICT ) """, r""" -CREATE TABLE public.checker_model_budget_configurations ( +CREATE TABLE public.model_usage_budget_configurations ( id uuid NOT NULL, project_id uuid NOT NULL, configuration_version integer NOT NULL, configuration_digest varchar(71) NOT NULL, + authorized_total_nanodollars bigint NOT NULL, + externally_reconciled_prior_spend_nanodollars bigint NOT NULL, lifetime_limit_nanodollars bigint NOT NULL, operation_id uuid NOT NULL, actor_profile_id uuid NOT NULL, authorization_evidence_id uuid NOT NULL, created_at timestamptz DEFAULT clock_timestamp() NOT NULL, - CONSTRAINT pk_checker_model_budget_configurations PRIMARY KEY(id), - CONSTRAINT ck_checker_model_budget_configurations_id_uuid7 CHECK ( + CONSTRAINT pk_model_usage_budget_configurations PRIMARY KEY(id), + CONSTRAINT ck_model_usage_budget_configurations_id_uuid7 CHECK ( (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), - CONSTRAINT ck_checker_model_budget_configurations_values CHECK ( - configuration_version>0 AND lifetime_limit_nanodollars>0 AND + CONSTRAINT ck_model_usage_budget_configurations_values CHECK ( + configuration_version>0 AND authorized_total_nanodollars>0 AND + externally_reconciled_prior_spend_nanodollars>=0 AND + lifetime_limit_nanodollars>0 AND + externally_reconciled_prior_spend_nanodollars+lifetime_limit_nanodollars + <=authorized_total_nanodollars AND configuration_digest ~ '^sha256:[0-9a-f]{64}$'), - CONSTRAINT uq_checker_model_budget_configurations_version + CONSTRAINT uq_model_usage_budget_configurations_version UNIQUE(project_id,configuration_version), - CONSTRAINT uq_checker_model_budget_configurations_identity + CONSTRAINT uq_model_usage_budget_configurations_identity UNIQUE(id,project_id,configuration_version,configuration_digest), - CONSTRAINT uq_checker_model_budget_configurations_attempt_identity + CONSTRAINT uq_model_usage_budget_configurations_attempt_identity UNIQUE(id,configuration_version,configuration_digest), - CONSTRAINT uq_checker_model_budget_configurations_id_project UNIQUE(id,project_id), - CONSTRAINT uq_checker_model_budget_configurations_digest UNIQUE(configuration_digest), - CONSTRAINT uq_checker_model_budget_configurations_operation UNIQUE(operation_id), - CONSTRAINT uq_checker_model_budget_configurations_authority + CONSTRAINT uq_model_usage_budget_configurations_id_project UNIQUE(id,project_id), + CONSTRAINT uq_model_usage_budget_configurations_digest UNIQUE(configuration_digest), + CONSTRAINT uq_model_usage_budget_configurations_operation UNIQUE(operation_id), + CONSTRAINT uq_model_usage_budget_configurations_authority UNIQUE(authorization_evidence_id), - CONSTRAINT fk_checker_model_budget_configurations_project FOREIGN KEY(project_id) + CONSTRAINT fk_model_usage_budget_configurations_project FOREIGN KEY(project_id) REFERENCES public.projects(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_budget_configurations_actor FOREIGN KEY(actor_profile_id) + CONSTRAINT fk_model_usage_budget_configurations_actor FOREIGN KEY(actor_profile_id) REFERENCES public.actor_profiles(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_budget_configurations_authority + CONSTRAINT fk_model_usage_budget_configurations_authority FOREIGN KEY(authorization_evidence_id) REFERENCES public.audit_events(id) ON DELETE RESTRICT ) """, r""" -CREATE TABLE public.checker_model_budget_accounts ( +CREATE TABLE public.model_usage_budget_accounts ( project_id uuid NOT NULL, current_configuration_id uuid NOT NULL, lifetime_reserved_nanodollars bigint DEFAULT 0 NOT NULL, lifetime_settled_nanodollars bigint DEFAULT 0 NOT NULL, - CONSTRAINT pk_checker_model_budget_accounts PRIMARY KEY(project_id), - CONSTRAINT uq_checker_model_budget_accounts_configuration + CONSTRAINT pk_model_usage_budget_accounts PRIMARY KEY(project_id), + CONSTRAINT uq_model_usage_budget_accounts_configuration UNIQUE(current_configuration_id), - CONSTRAINT ck_checker_model_budget_accounts_values CHECK ( + CONSTRAINT ck_model_usage_budget_accounts_values CHECK ( lifetime_reserved_nanodollars>=0 AND lifetime_settled_nanodollars>=0 AND lifetime_reserved_nanodollars::numeric+lifetime_settled_nanodollars::numeric <=9000000000000000000), - CONSTRAINT fk_checker_model_budget_accounts_project FOREIGN KEY(project_id) + CONSTRAINT fk_model_usage_budget_accounts_project FOREIGN KEY(project_id) REFERENCES public.projects(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_budget_accounts_configuration + CONSTRAINT fk_model_usage_budget_accounts_configuration FOREIGN KEY(current_configuration_id,project_id) - REFERENCES public.checker_model_budget_configurations(id,project_id) + REFERENCES public.model_usage_budget_configurations(id,project_id) + ON DELETE RESTRICT +) +""", + r""" +CREATE TABLE public.checker_model_usage_envelopes ( + id uuid NOT NULL, + project_id uuid NOT NULL, + task_id uuid NOT NULL, + assignment_id uuid NOT NULL, + submission_id uuid NOT NULL, + submission_version integer NOT NULL, + requirement_id varchar(100) NOT NULL, + registry_entry_digest varchar(71) NOT NULL, + configuration_sha256 varchar(71) NOT NULL, + profile_id uuid NOT NULL, + profile_version integer NOT NULL, + profile_digest varchar(71) NOT NULL, + created_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_checker_model_usage_envelopes PRIMARY KEY(id), + CONSTRAINT ck_checker_model_usage_envelopes_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_checker_model_usage_envelopes_values CHECK ( + submission_version>0 AND profile_version>0 AND + requirement_id ~ '^[a-z][a-z0-9_.-]{0,99}$' AND + registry_entry_digest ~ '^sha256:[0-9a-f]{64}$' AND + configuration_sha256 ~ '^sha256:[0-9a-f]{64}$' AND + profile_digest ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT uq_checker_model_usage_envelopes_stable_identity UNIQUE( + project_id,assignment_id,submission_id,submission_version,requirement_id, + registry_entry_digest,configuration_sha256,profile_id,profile_version, + profile_digest), + CONSTRAINT fk_checker_model_usage_envelopes_project FOREIGN KEY(project_id) + REFERENCES public.projects(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_envelopes_task_project FOREIGN KEY(task_id,project_id) + REFERENCES public.workstream_tasks(id,project_id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_envelopes_assignment FOREIGN KEY(assignment_id) + REFERENCES public.task_assignments(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_envelopes_submission FOREIGN KEY( + submission_id,task_id,submission_version) + REFERENCES public.submissions(id,task_id,version) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_envelopes_profile FOREIGN KEY( + profile_id,profile_version,profile_digest) + REFERENCES public.model_usage_profiles(id,profile_version,profile_digest) ON DELETE RESTRICT ) """, r""" -CREATE TABLE public.checker_model_call_slots ( +CREATE TABLE public.checker_model_usage_slots ( id uuid NOT NULL, + envelope_id uuid NOT NULL, project_id uuid NOT NULL, task_id uuid NOT NULL, assignment_id uuid NOT NULL, @@ -996,39 +1078,128 @@ def downgrade() -> None: slot_index integer NOT NULL, first_request_sha256 varchar(71) NOT NULL, created_at timestamptz DEFAULT clock_timestamp() NOT NULL, - CONSTRAINT pk_checker_model_call_slots PRIMARY KEY(id), - CONSTRAINT ck_checker_model_call_slots_id_uuid7 CHECK ( + CONSTRAINT pk_checker_model_usage_slots PRIMARY KEY(id), + CONSTRAINT ck_checker_model_usage_slots_id_uuid7 CHECK ( (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), - CONSTRAINT ck_checker_model_call_slots_values CHECK ( - submission_version>0 AND profile_version>0 AND slot_index=0 AND + CONSTRAINT ck_checker_model_usage_slots_values CHECK ( + submission_version>0 AND profile_version>0 AND slot_index BETWEEN 0 AND 99 AND requirement_id ~ '^[a-z][a-z0-9_.-]{0,99}$' AND registry_entry_digest ~ '^sha256:[0-9a-f]{64}$' AND configuration_sha256 ~ '^sha256:[0-9a-f]{64}$' AND profile_digest ~ '^sha256:[0-9a-f]{64}$' AND first_request_sha256 ~ '^sha256:[0-9a-f]{64}$'), - CONSTRAINT uq_checker_model_call_slots_stable_identity UNIQUE( + CONSTRAINT uq_checker_model_usage_slots_stable_identity UNIQUE( project_id,assignment_id,submission_id,submission_version,requirement_id, registry_entry_digest,configuration_sha256,profile_id,profile_version, profile_digest,slot_index), - CONSTRAINT fk_checker_model_call_slots_project FOREIGN KEY(project_id) + CONSTRAINT fk_checker_model_usage_slots_envelope FOREIGN KEY(envelope_id) + REFERENCES public.checker_model_usage_envelopes(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_slots_project FOREIGN KEY(project_id) REFERENCES public.projects(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_slots_task_project FOREIGN KEY(task_id,project_id) + CONSTRAINT fk_checker_model_usage_slots_task_project FOREIGN KEY(task_id,project_id) REFERENCES public.workstream_tasks(id,project_id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_slots_assignment FOREIGN KEY(assignment_id) + CONSTRAINT fk_checker_model_usage_slots_assignment FOREIGN KEY(assignment_id) REFERENCES public.task_assignments(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_slots_submission FOREIGN KEY( + CONSTRAINT fk_checker_model_usage_slots_submission FOREIGN KEY( submission_id,task_id,submission_version) REFERENCES public.submissions(id,task_id,version) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_slots_profile FOREIGN KEY( + CONSTRAINT fk_checker_model_usage_slots_profile FOREIGN KEY( + profile_id,profile_version,profile_digest) + REFERENCES public.model_usage_profiles(id,profile_version,profile_digest) + ON DELETE RESTRICT +) +""", + r""" +ALTER TABLE public.project_guide_compilation_attempts + ADD CONSTRAINT uq_compilation_attempt_model_usage_owner UNIQUE( + id,project_id,guide_id,source_snapshot_id,setup_run_id,setup_generation, + runtime_configuration_hash) +""", + r""" +CREATE TABLE public.setup_model_usage_envelopes ( + id uuid NOT NULL, + compilation_attempt_id uuid NOT NULL, + project_id uuid NOT NULL, + guide_id uuid NOT NULL, + source_snapshot_id uuid NOT NULL, + setup_run_id uuid NOT NULL, + setup_generation bigint NOT NULL, + runtime_configuration_hash varchar(71) NOT NULL, + profile_id uuid NOT NULL, + profile_version integer NOT NULL, + profile_digest varchar(71) NOT NULL, + created_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_setup_model_usage_envelopes PRIMARY KEY(id), + CONSTRAINT ck_setup_model_usage_envelopes_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_setup_model_usage_envelopes_values CHECK ( + setup_generation>0 AND profile_version>0 AND + runtime_configuration_hash ~ '^sha256:[0-9a-f]{64}$' AND + profile_digest ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT uq_setup_model_usage_envelopes_stable_identity UNIQUE( + compilation_attempt_id,runtime_configuration_hash,profile_id,profile_version, + profile_digest), + CONSTRAINT fk_setup_model_usage_envelopes_attempt FOREIGN KEY( + compilation_attempt_id,project_id,guide_id,source_snapshot_id,setup_run_id, + setup_generation,runtime_configuration_hash) + REFERENCES public.project_guide_compilation_attempts( + id,project_id,guide_id,source_snapshot_id,setup_run_id,setup_generation, + runtime_configuration_hash) ON DELETE RESTRICT, + CONSTRAINT fk_setup_model_usage_envelopes_profile FOREIGN KEY( profile_id,profile_version,profile_digest) - REFERENCES public.checker_model_profiles(id,profile_version,profile_digest) + REFERENCES public.model_usage_profiles(id,profile_version,profile_digest) ON DELETE RESTRICT ) """, r""" -CREATE TABLE public.checker_model_call_attempts ( +CREATE TABLE public.setup_model_usage_slots ( id uuid NOT NULL, - slot_id uuid NOT NULL, + envelope_id uuid NOT NULL, + compilation_attempt_id uuid NOT NULL, + project_id uuid NOT NULL, + guide_id uuid NOT NULL, + source_snapshot_id uuid NOT NULL, + setup_run_id uuid NOT NULL, + setup_generation bigint NOT NULL, + runtime_configuration_hash varchar(71) NOT NULL, + profile_id uuid NOT NULL, + profile_version integer NOT NULL, + profile_digest varchar(71) NOT NULL, + phase varchar(32) NOT NULL, + call_index integer NOT NULL, + request_sha256 varchar(71) NOT NULL, + created_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_setup_model_usage_slots PRIMARY KEY(id), + CONSTRAINT ck_setup_model_usage_slots_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_setup_model_usage_slots_values CHECK ( + setup_generation>0 AND profile_version>0 AND call_index BETWEEN 0 AND 99 AND + phase IN ('draft','review','correction') AND + runtime_configuration_hash ~ '^sha256:[0-9a-f]{64}$' AND + profile_digest ~ '^sha256:[0-9a-f]{64}$' AND + request_sha256 ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT uq_setup_model_usage_slots_stable_identity UNIQUE( + compilation_attempt_id,runtime_configuration_hash,profile_id,profile_version, + profile_digest,phase,call_index), + CONSTRAINT fk_setup_model_usage_slots_envelope FOREIGN KEY(envelope_id) + REFERENCES public.setup_model_usage_envelopes(id) ON DELETE RESTRICT, + CONSTRAINT fk_setup_model_usage_slots_attempt FOREIGN KEY( + compilation_attempt_id,project_id,guide_id,source_snapshot_id,setup_run_id, + setup_generation,runtime_configuration_hash) + REFERENCES public.project_guide_compilation_attempts( + id,project_id,guide_id,source_snapshot_id,setup_run_id,setup_generation, + runtime_configuration_hash) ON DELETE RESTRICT, + CONSTRAINT fk_setup_model_usage_slots_profile FOREIGN KEY( + profile_id,profile_version,profile_digest) + REFERENCES public.model_usage_profiles(id,profile_version,profile_digest) + ON DELETE RESTRICT +) +""", + r""" +CREATE TABLE public.model_usage_effects ( + id uuid NOT NULL, + checker_slot_id uuid, + setup_slot_id uuid, attempt_number integer NOT NULL, internal_call_request_id uuid NOT NULL, parent_attempt_id uuid, @@ -1069,76 +1240,81 @@ def downgrade() -> None: prepared_at timestamptz DEFAULT clock_timestamp() NOT NULL, dispatched_at timestamptz, terminal_at timestamptz, - CONSTRAINT pk_checker_model_call_attempts PRIMARY KEY(id), - CONSTRAINT ck_checker_model_call_attempts_id_uuid7 CHECK ( + CONSTRAINT pk_model_usage_effects PRIMARY KEY(id), + CONSTRAINT ck_model_usage_effects_id_uuid7 CHECK ( (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), - CONSTRAINT ck_checker_model_call_attempts_internal_id_uuid7 CHECK ( + CONSTRAINT ck_model_usage_effects_internal_id_uuid7 CHECK ( (get_byte(uuid_send(internal_call_request_id),6)>>4)=7 AND (get_byte(uuid_send(internal_call_request_id),8)&192)=128), - CONSTRAINT ck_checker_model_call_attempts_values CHECK ( + CONSTRAINT ck_model_usage_effects_values CHECK ( attempt_number>0 AND request_sha256 ~ '^sha256:[0-9a-f]{64}$' AND state IN ('prepared','dispatching','uncertain','completed','known_failed','known_no_effect') AND (provider_response_id IS NULL OR provider_response_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$') AND (reserved_nanodollars IS NULL OR reserved_nanodollars>0) AND (settled_nanodollars IS NULL OR settled_nanodollars>=0)), - CONSTRAINT uq_checker_model_call_attempts_number UNIQUE(slot_id,attempt_number), - CONSTRAINT uq_checker_model_call_attempts_internal_request UNIQUE(internal_call_request_id), - CONSTRAINT uq_checker_model_call_attempts_prepare_authority + CONSTRAINT ck_model_usage_effects_exact_owner CHECK ( + num_nonnulls(checker_slot_id,setup_slot_id)=1), + CONSTRAINT uq_model_usage_effects_checker_number UNIQUE(checker_slot_id,attempt_number), + CONSTRAINT uq_model_usage_effects_setup_number UNIQUE(setup_slot_id,attempt_number), + CONSTRAINT uq_model_usage_effects_internal_request UNIQUE(internal_call_request_id), + CONSTRAINT uq_model_usage_effects_prepare_authority UNIQUE(prepared_authorization_evidence_id), - CONSTRAINT uq_checker_model_call_attempts_dispatch_authority + CONSTRAINT uq_model_usage_effects_dispatch_authority UNIQUE(dispatch_authorization_evidence_id), - CONSTRAINT uq_checker_model_call_attempts_observation_authority + CONSTRAINT uq_model_usage_effects_observation_authority UNIQUE(observation_authorization_evidence_id), - CONSTRAINT uq_checker_model_call_attempts_terminal_authority + CONSTRAINT uq_model_usage_effects_terminal_authority UNIQUE(terminal_authorization_evidence_id), - CONSTRAINT uq_checker_model_call_attempts_settlement_authority + CONSTRAINT uq_model_usage_effects_settlement_authority UNIQUE(settlement_authorization_evidence_id), - CONSTRAINT fk_checker_model_call_attempts_slot FOREIGN KEY(slot_id) - REFERENCES public.checker_model_call_slots(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_attempts_parent FOREIGN KEY(parent_attempt_id) - REFERENCES public.checker_model_call_attempts(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_attempts_budget FOREIGN KEY( + CONSTRAINT fk_model_usage_effects_checker_slot FOREIGN KEY(checker_slot_id) + REFERENCES public.checker_model_usage_slots(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_setup_slot FOREIGN KEY(setup_slot_id) + REFERENCES public.setup_model_usage_slots(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_parent FOREIGN KEY(parent_attempt_id) + REFERENCES public.model_usage_effects(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_budget FOREIGN KEY( budget_configuration_id,budget_configuration_version,budget_configuration_digest) - REFERENCES public.checker_model_budget_configurations( + REFERENCES public.model_usage_budget_configurations( id,configuration_version,configuration_digest) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_attempts_prepare_authority + CONSTRAINT fk_model_usage_effects_prepare_authority FOREIGN KEY(prepared_authorization_evidence_id) REFERENCES public.audit_events(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_attempts_dispatch_authority + CONSTRAINT fk_model_usage_effects_dispatch_authority FOREIGN KEY(dispatch_authorization_evidence_id) REFERENCES public.audit_events(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_attempts_observation_authority + CONSTRAINT fk_model_usage_effects_observation_authority FOREIGN KEY(observation_authorization_evidence_id) REFERENCES public.audit_events(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_attempts_terminal_authority + CONSTRAINT fk_model_usage_effects_terminal_authority FOREIGN KEY(terminal_authorization_evidence_id) REFERENCES public.audit_events(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_attempts_settlement_authority + CONSTRAINT fk_model_usage_effects_settlement_authority FOREIGN KEY(settlement_authorization_evidence_id) REFERENCES public.audit_events(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_attempts_request_binding FOREIGN KEY(request_binding_id) + CONSTRAINT fk_model_usage_effects_request_binding FOREIGN KEY(request_binding_id) REFERENCES public.artifact_bindings(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_attempts_request_content FOREIGN KEY(request_content_id) + CONSTRAINT fk_model_usage_effects_request_content FOREIGN KEY(request_content_id) REFERENCES public.artifact_contents(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_attempts_request_put FOREIGN KEY(request_put_attempt_id) + CONSTRAINT fk_model_usage_effects_request_put FOREIGN KEY(request_put_attempt_id) REFERENCES public.artifact_put_attempts(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_attempts_request_verification + CONSTRAINT fk_model_usage_effects_request_verification FOREIGN KEY(request_verification_receipt_id) REFERENCES public.artifact_verification_receipts(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_attempts_response_binding FOREIGN KEY(response_binding_id) + CONSTRAINT fk_model_usage_effects_response_binding FOREIGN KEY(response_binding_id) REFERENCES public.artifact_bindings(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_attempts_response_content FOREIGN KEY(response_content_id) + CONSTRAINT fk_model_usage_effects_response_content FOREIGN KEY(response_content_id) REFERENCES public.artifact_contents(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_attempts_response_put FOREIGN KEY(response_put_attempt_id) + CONSTRAINT fk_model_usage_effects_response_put FOREIGN KEY(response_put_attempt_id) REFERENCES public.artifact_put_attempts(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_attempts_response_verification + CONSTRAINT fk_model_usage_effects_response_verification FOREIGN KEY(response_verification_receipt_id) REFERENCES public.artifact_verification_receipts(id) ON DELETE RESTRICT ) """, r""" -CREATE TABLE public.checker_model_call_recoveries ( +CREATE TABLE public.checker_model_usage_recoveries ( id uuid NOT NULL, attempt_id uuid NOT NULL, checker_run_id uuid NOT NULL, @@ -1146,18 +1322,18 @@ def downgrade() -> None: worker_lease_generation integer NOT NULL, authorization_evidence_id uuid NOT NULL, created_at timestamptz DEFAULT clock_timestamp() NOT NULL, - CONSTRAINT pk_checker_model_call_recoveries PRIMARY KEY(id), - CONSTRAINT ck_checker_model_call_recoveries_id_uuid7 CHECK ( + CONSTRAINT pk_checker_model_usage_recoveries PRIMARY KEY(id), + CONSTRAINT ck_checker_model_usage_recoveries_id_uuid7 CHECK ( (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), - CONSTRAINT ck_checker_model_call_recoveries_generation CHECK(worker_lease_generation>0), - CONSTRAINT uq_checker_model_call_recoveries_attachment UNIQUE( + CONSTRAINT ck_checker_model_usage_recoveries_generation CHECK(worker_lease_generation>0), + CONSTRAINT uq_checker_model_usage_recoveries_attachment UNIQUE( attempt_id,checker_run_id,worker_lease_id,worker_lease_generation), - CONSTRAINT uq_checker_model_call_recoveries_authority UNIQUE(authorization_evidence_id), - CONSTRAINT fk_checker_model_call_recoveries_attempt FOREIGN KEY(attempt_id) - REFERENCES public.checker_model_call_attempts(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_recoveries_run FOREIGN KEY(checker_run_id) + CONSTRAINT uq_checker_model_usage_recoveries_authority UNIQUE(authorization_evidence_id), + CONSTRAINT fk_checker_model_usage_recoveries_attempt FOREIGN KEY(attempt_id) + REFERENCES public.model_usage_effects(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_recoveries_run FOREIGN KEY(checker_run_id) REFERENCES public.checker_runs(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_call_recoveries_authority FOREIGN KEY(authorization_evidence_id) + CONSTRAINT fk_checker_model_usage_recoveries_authority FOREIGN KEY(authorization_evidence_id) REFERENCES public.audit_events(id) ON DELETE RESTRICT ) """, @@ -1166,20 +1342,20 @@ def downgrade() -> None: _FUNCTIONS = ( r""" -CREATE FUNCTION public.checker_model_canonical_json(value jsonb) RETURNS text +CREATE FUNCTION public.model_usage_canonical_json(value jsonb) RETURNS text LANGUAGE plpgsql IMMUTABLE STRICT SET search_path=pg_catalog,public,pg_temp AS $$ DECLARE encoded text; BEGIN CASE pg_catalog.jsonb_typeof(value) WHEN 'object' THEN SELECT '{'||coalesce(pg_catalog.string_agg( - pg_catalog.to_json(item_key)::text||':'||public.checker_model_canonical_json(item_value), + pg_catalog.to_json(item_key)::text||':'||public.model_usage_canonical_json(item_value), ',' ORDER BY item_key COLLATE "C"),'')||'}' INTO encoded FROM pg_catalog.jsonb_each(value) AS items(item_key,item_value); RETURN encoded; WHEN 'array' THEN SELECT '['||coalesce(pg_catalog.string_agg( - public.checker_model_canonical_json(item_value),',' ORDER BY item_order),'')||']' + public.model_usage_canonical_json(item_value),',' ORDER BY item_order),'')||']' INTO encoded FROM pg_catalog.jsonb_array_elements(value) WITH ORDINALITY AS items(item_value,item_order); RETURN encoded; @@ -1188,30 +1364,37 @@ def downgrade() -> None: END $$ """, r""" -CREATE FUNCTION public.checker_model_json_digest(value jsonb) RETURNS varchar +CREATE FUNCTION public.model_usage_json_digest(value jsonb) RETURNS varchar LANGUAGE sql IMMUTABLE STRICT SET search_path=pg_catalog,public,pg_temp AS $$ SELECT 'sha256:'||pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to( - public.checker_model_canonical_json(value),'UTF8')),'hex') + public.model_usage_canonical_json(value),'UTF8')),'hex') $$ """, r""" -CREATE FUNCTION public.checker_model_profile_authority_digest( - r public.checker_model_profiles +CREATE FUNCTION public.model_usage_profile_authority_digest( + r public.model_usage_profiles ) RETURNS varchar LANGUAGE sql IMMUTABLE STRICT SET search_path=pg_catalog,public,pg_temp AS $$ WITH facts(value) AS (SELECT pg_catalog.jsonb_build_object( 'operation_id',r.operation_id,'actor_profile_id',r.actor_profile_id, 'spec',pg_catalog.jsonb_build_object( 'project_id',r.project_id,'profile_version',r.profile_version, - 'registry_entry_id',r.registry_entry_id, - 'registry_entry_digest',r.registry_entry_digest,'provider',r.provider, - 'api',r.api,'model_snapshot',r.model_snapshot,'settings',r.generation_settings, + 'provider',r.provider,'api',r.api,'model_snapshot',r.model_snapshot, + 'settings',r.generation_settings, 'maximum_request_bytes',r.maximum_request_bytes, 'maximum_output_bytes',r.maximum_output_bytes, 'maximum_response_bytes',r.maximum_response_bytes, 'maximum_input_tokens',r.maximum_input_tokens, - 'maximum_output_tokens',r.maximum_output_tokens,'deadline_ms',r.deadline_ms, - 'call_slots',r.call_slots,'pricing',pg_catalog.jsonb_build_object( + 'maximum_output_tokens',r.maximum_output_tokens, + 'maximum_calls',r.maximum_calls, + 'maximum_total_request_bytes',r.maximum_total_request_bytes, + 'maximum_total_response_bytes',r.maximum_total_response_bytes, + 'maximum_total_input_tokens',r.maximum_total_input_tokens, + 'maximum_total_output_tokens',r.maximum_total_output_tokens, + 'allowed_tools',r.allowed_tools, + 'maximum_tool_calls',r.maximum_tool_calls, + 'maximum_tool_output_bytes',r.maximum_tool_output_bytes, + 'deadline_ms',r.deadline_ms,'pricing',pg_catalog.jsonb_build_object( 'pricing_policy_id',r.pricing_policy_id, 'pricing_policy_version',r.pricing_policy_version, 'pricing_policy_digest',r.pricing_policy_digest, @@ -1219,51 +1402,50 @@ def downgrade() -> None: 'cached_input_nanodollars_per_token',r.cached_input_rate, 'output_nanodollars_per_token',r.output_rate)), 'profile_digest',r.profile_digest)) - SELECT public.checker_model_json_digest(pg_catalog.jsonb_build_object( + SELECT public.model_usage_json_digest(pg_catalog.jsonb_build_object( 'resource_context',pg_catalog.jsonb_build_object( 'resource_type','project_model_administration','resource_id',r.operation_id, 'project_id',r.project_id,'actor_profile_id',r.actor_profile_id, 'action_id','project.model_profile.provision','facts',facts.value, - 'facts_digest',public.checker_model_json_digest(pg_catalog.jsonb_build_object( + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( 'action','project.model_profile.provision','facts',facts.value))))) FROM facts $$ """, r""" -CREATE FUNCTION public.checker_model_budget_authority_digest( - r public.checker_model_budget_configurations +CREATE FUNCTION public.model_usage_budget_authority_digest( + r public.model_usage_budget_configurations ) RETURNS varchar LANGUAGE sql IMMUTABLE STRICT SET search_path=pg_catalog,public,pg_temp AS $$ WITH facts(value) AS (SELECT pg_catalog.jsonb_build_object( 'operation_id',r.operation_id,'actor_profile_id',r.actor_profile_id, 'spec',pg_catalog.jsonb_build_object('project_id',r.project_id, 'configuration_version',r.configuration_version, + 'authorized_total_nanodollars',r.authorized_total_nanodollars, + 'externally_reconciled_prior_spend_nanodollars', + r.externally_reconciled_prior_spend_nanodollars, 'lifetime_limit_nanodollars',r.lifetime_limit_nanodollars), 'configuration_digest',r.configuration_digest)) - SELECT public.checker_model_json_digest(pg_catalog.jsonb_build_object( + SELECT public.model_usage_json_digest(pg_catalog.jsonb_build_object( 'resource_context',pg_catalog.jsonb_build_object( 'resource_type','project_model_administration','resource_id',r.operation_id, 'project_id',r.project_id,'actor_profile_id',r.actor_profile_id, 'action_id','project.model_budget.configure','facts',facts.value, - 'facts_digest',public.checker_model_json_digest(pg_catalog.jsonb_build_object( + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( 'action','project.model_budget.configure','facts',facts.value))))) FROM facts $$ """, r""" -CREATE FUNCTION public.guard_checker_model_profile() RETURNS trigger +CREATE FUNCTION public.guard_model_usage_profile() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ DECLARE expected_version integer; DECLARE authority public.audit_events%ROWTYPE; -DECLARE registry_digest varchar; -DECLARE registry_phase varchar; BEGIN IF TG_OP<>'INSERT' THEN RAISE EXCEPTION 'checker model profiles are immutable' USING ERRCODE='55000'; END IF; - IF NEW.profile_digest<>public.checker_model_json_digest( + IF NEW.profile_digest<>public.model_usage_json_digest( pg_catalog.jsonb_build_object( 'project_id',NEW.project_id,'profile_version',NEW.profile_version, - 'registry_entry_id',NEW.registry_entry_id, - 'registry_entry_digest',NEW.registry_entry_digest, 'provider',NEW.provider,'api',NEW.api, 'model_snapshot',NEW.model_snapshot,'settings',NEW.generation_settings, 'maximum_request_bytes',NEW.maximum_request_bytes, @@ -1271,7 +1453,15 @@ def downgrade() -> None: 'maximum_response_bytes',NEW.maximum_response_bytes, 'maximum_input_tokens',NEW.maximum_input_tokens, 'maximum_output_tokens',NEW.maximum_output_tokens, - 'deadline_ms',NEW.deadline_ms,'call_slots',NEW.call_slots, + 'maximum_calls',NEW.maximum_calls, + 'maximum_total_request_bytes',NEW.maximum_total_request_bytes, + 'maximum_total_response_bytes',NEW.maximum_total_response_bytes, + 'maximum_total_input_tokens',NEW.maximum_total_input_tokens, + 'maximum_total_output_tokens',NEW.maximum_total_output_tokens, + 'allowed_tools',NEW.allowed_tools, + 'maximum_tool_calls',NEW.maximum_tool_calls, + 'maximum_tool_output_bytes',NEW.maximum_tool_output_bytes, + 'deadline_ms',NEW.deadline_ms, 'pricing',pg_catalog.jsonb_build_object( 'pricing_policy_id',NEW.pricing_policy_id, 'pricing_policy_version',NEW.pricing_policy_version, @@ -1292,18 +1482,12 @@ def downgrade() -> None: authority.denial_code IS NOT NULL OR authority.after_facts::jsonb<>pg_catalog.jsonb_build_object( 'allowed',true,'resource_context_digest', - public.checker_model_profile_authority_digest(NEW)) THEN + public.model_usage_profile_authority_digest(NEW)) THEN RAISE EXCEPTION 'checker model profile authority is invalid' USING ERRCODE='23514'; END IF; - SELECT entry_digest,phase INTO registry_digest,registry_phase - FROM public.external_checker_registry_entries WHERE id=NEW.registry_entry_id FOR SHARE; - IF registry_digest IS DISTINCT FROM NEW.registry_entry_digest OR - registry_phase IS DISTINCT FROM 'post_submit' THEN - RAISE EXCEPTION 'checker model profile registry binding is invalid' USING ERRCODE='23514'; - END IF; PERFORM pg_advisory_xact_lock(hashtextextended('checker-model-profile:'||NEW.project_id,0)); SELECT coalesce(max(profile_version),0)+1 INTO expected_version - FROM public.checker_model_profiles WHERE project_id=NEW.project_id; + FROM public.model_usage_profiles WHERE project_id=NEW.project_id; IF NEW.profile_version<>expected_version THEN RAISE EXCEPTION 'checker model profile version is not sequential' USING ERRCODE='23514'; END IF; @@ -1311,19 +1495,22 @@ def downgrade() -> None: END $$ """, r""" -CREATE FUNCTION public.guard_checker_model_budget_configuration() RETURNS trigger +CREATE FUNCTION public.guard_model_usage_budget_configuration() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ -DECLARE account public.checker_model_budget_accounts%ROWTYPE; -DECLARE current_version integer; +DECLARE account public.model_usage_budget_accounts%ROWTYPE; +DECLARE account_configuration public.model_usage_budget_configurations%ROWTYPE; DECLARE authority public.audit_events%ROWTYPE; BEGIN IF TG_OP<>'INSERT' THEN RAISE EXCEPTION 'checker model budget configurations are immutable' USING ERRCODE='55000'; END IF; - IF NEW.configuration_digest<>public.checker_model_json_digest( + IF NEW.configuration_digest<>public.model_usage_json_digest( pg_catalog.jsonb_build_object( 'project_id',NEW.project_id, 'configuration_version',NEW.configuration_version, + 'authorized_total_nanodollars',NEW.authorized_total_nanodollars, + 'externally_reconciled_prior_spend_nanodollars', + NEW.externally_reconciled_prior_spend_nanodollars, 'lifetime_limit_nanodollars',NEW.lifetime_limit_nanodollars)) THEN RAISE EXCEPTION 'checker model budget configuration digest is invalid' USING ERRCODE='23514'; @@ -1339,21 +1526,24 @@ def downgrade() -> None: authority.denial_code IS NOT NULL OR authority.after_facts::jsonb<>pg_catalog.jsonb_build_object( 'allowed',true,'resource_context_digest', - public.checker_model_budget_authority_digest(NEW)) THEN + public.model_usage_budget_authority_digest(NEW)) THEN RAISE EXCEPTION 'checker model budget authority is invalid' USING ERRCODE='23514'; END IF; PERFORM pg_advisory_xact_lock(hashtextextended('checker-model-budget:'||NEW.project_id,0)); - SELECT * INTO account FROM public.checker_model_budget_accounts + SELECT * INTO account FROM public.model_usage_budget_accounts WHERE project_id=NEW.project_id FOR UPDATE; IF account.project_id IS NULL THEN IF NEW.configuration_version<>1 THEN RAISE EXCEPTION 'initial checker model budget version must be one' USING ERRCODE='23514'; END IF; ELSE - SELECT configuration_version INTO current_version - FROM public.checker_model_budget_configurations + SELECT * INTO account_configuration + FROM public.model_usage_budget_configurations WHERE id=account.current_configuration_id; - IF NEW.configuration_version<>current_version+1 OR + IF NEW.configuration_version<>account_configuration.configuration_version+1 OR + NEW.authorized_total_nanodollars None: END $$ """, r""" -CREATE FUNCTION public.guard_checker_model_budget_account() RETURNS trigger +CREATE FUNCTION public.guard_model_usage_budget_account() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ -DECLARE chosen public.checker_model_budget_configurations%ROWTYPE; -DECLARE prior public.checker_model_budget_configurations%ROWTYPE; +DECLARE chosen public.model_usage_budget_configurations%ROWTYPE; +DECLARE prior public.model_usage_budget_configurations%ROWTYPE; BEGIN IF TG_OP='DELETE' THEN RAISE EXCEPTION 'checker model budget accounts cannot be deleted' USING ERRCODE='55000'; END IF; - SELECT * INTO chosen FROM public.checker_model_budget_configurations + SELECT * INTO chosen FROM public.model_usage_budget_configurations WHERE id=NEW.current_configuration_id AND project_id=NEW.project_id FOR SHARE; IF chosen.id IS NULL OR NEW.lifetime_reserved_nanodollars+NEW.lifetime_settled_nanodollars> @@ -1389,7 +1579,7 @@ def downgrade() -> None: RAISE EXCEPTION 'checker model budget project is immutable' USING ERRCODE='55000'; END IF; IF NEW.current_configuration_id<>OLD.current_configuration_id THEN - SELECT * INTO prior FROM public.checker_model_budget_configurations + SELECT * INTO prior FROM public.model_usage_budget_configurations WHERE id=OLD.current_configuration_id FOR SHARE; IF ROW(NEW.lifetime_reserved_nanodollars,NEW.lifetime_settled_nanodollars) IS DISTINCT FROM @@ -1411,54 +1601,215 @@ def downgrade() -> None: END $$ """, r""" -CREATE FUNCTION public.guard_checker_model_call_slot() RETURNS trigger +CREATE FUNCTION public.guard_checker_model_usage_envelope() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ DECLARE assignment_task uuid; DECLARE assignment_project uuid; -DECLARE profile public.checker_model_profiles%ROWTYPE; +DECLARE profile public.model_usage_profiles%ROWTYPE; BEGIN IF TG_OP<>'INSERT' THEN - RAISE EXCEPTION 'checker model call slots are immutable' USING ERRCODE='55000'; + RAISE EXCEPTION 'checker model usage envelopes are immutable' USING ERRCODE='55000'; END IF; SELECT task_id,project_id INTO assignment_task,assignment_project FROM public.task_assignments WHERE id=NEW.assignment_id FOR SHARE; - SELECT * INTO profile FROM public.checker_model_profiles WHERE + SELECT * INTO profile FROM public.model_usage_profiles WHERE id=NEW.profile_id AND profile_version=NEW.profile_version AND profile_digest=NEW.profile_digest FOR SHARE; IF assignment_task IS DISTINCT FROM NEW.task_id OR assignment_project IS DISTINCT FROM NEW.project_id OR profile.id IS NULL OR - profile.project_id<>NEW.project_id OR - profile.registry_entry_digest<>NEW.registry_entry_digest THEN + profile.project_id<>NEW.project_id THEN + RAISE EXCEPTION 'invalid checker model usage envelope ownership' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.guard_checker_model_call_slot() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE envelope public.checker_model_usage_envelopes%ROWTYPE; +DECLARE profile public.model_usage_profiles%ROWTYPE; +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'checker model call slots are immutable' USING ERRCODE='55000'; + END IF; + SELECT * INTO envelope FROM public.checker_model_usage_envelopes + WHERE id=NEW.envelope_id FOR SHARE; + SELECT * INTO profile FROM public.model_usage_profiles + WHERE id=NEW.profile_id AND profile_version=NEW.profile_version AND + profile_digest=NEW.profile_digest FOR SHARE; + IF envelope.id IS NULL OR profile.id IS NULL OR + ROW(envelope.project_id,envelope.task_id,envelope.assignment_id, + envelope.submission_id,envelope.submission_version,envelope.requirement_id, + envelope.registry_entry_digest,envelope.configuration_sha256, + envelope.profile_id,envelope.profile_version,envelope.profile_digest) + IS DISTINCT FROM + ROW(NEW.project_id,NEW.task_id,NEW.assignment_id,NEW.submission_id, + NEW.submission_version,NEW.requirement_id,NEW.registry_entry_digest, + NEW.configuration_sha256,NEW.profile_id,NEW.profile_version, + NEW.profile_digest) OR NEW.slot_index>=profile.maximum_calls THEN RAISE EXCEPTION 'invalid checker model call slot ownership' USING ERRCODE='23514'; END IF; RETURN NEW; END $$ """, r""" -CREATE FUNCTION public.guard_checker_model_call_attempt() RETURNS trigger +CREATE FUNCTION public.guard_setup_model_usage_envelope() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ -DECLARE slot public.checker_model_call_slots%ROWTYPE; -DECLARE profile public.checker_model_profiles%ROWTYPE; -DECLARE budget public.checker_model_budget_configurations%ROWTYPE; -DECLARE account public.checker_model_budget_accounts%ROWTYPE; -DECLARE parent public.checker_model_call_attempts%ROWTYPE; -DECLARE maximum_reservation numeric; +DECLARE attempt public.project_guide_compilation_attempts%ROWTYPE; +DECLARE profile public.model_usage_profiles%ROWTYPE; +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'setup model usage envelopes are immutable' USING ERRCODE='55000'; + END IF; + SELECT * INTO attempt FROM public.project_guide_compilation_attempts + WHERE id=NEW.compilation_attempt_id FOR SHARE; + SELECT * INTO profile FROM public.model_usage_profiles WHERE + id=NEW.profile_id AND profile_version=NEW.profile_version AND + profile_digest=NEW.profile_digest FOR SHARE; + IF attempt.id IS NULL OR profile.id IS NULL OR + attempt.project_id IS DISTINCT FROM NEW.project_id OR + attempt.guide_id IS DISTINCT FROM NEW.guide_id OR + attempt.source_snapshot_id IS DISTINCT FROM NEW.source_snapshot_id OR + attempt.setup_run_id IS DISTINCT FROM NEW.setup_run_id OR + attempt.setup_generation IS DISTINCT FROM NEW.setup_generation OR + attempt.runtime_configuration_hash IS DISTINCT FROM NEW.runtime_configuration_hash OR + profile.project_id::text IS DISTINCT FROM NEW.project_id::text THEN + RAISE EXCEPTION 'invalid setup model usage envelope ownership' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.guard_setup_model_usage_slot() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE attempt public.project_guide_compilation_attempts%ROWTYPE; +DECLARE profile public.model_usage_profiles%ROWTYPE; +DECLARE envelope public.setup_model_usage_envelopes%ROWTYPE; +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'setup model usage slots are immutable' USING ERRCODE='55000'; + END IF; + SELECT * INTO attempt FROM public.project_guide_compilation_attempts + WHERE id=NEW.compilation_attempt_id FOR SHARE; + SELECT * INTO profile FROM public.model_usage_profiles WHERE + id=NEW.profile_id AND profile_version=NEW.profile_version AND + profile_digest=NEW.profile_digest FOR SHARE; + SELECT * INTO envelope FROM public.setup_model_usage_envelopes + WHERE id=NEW.envelope_id FOR SHARE; + IF attempt.id IS NULL OR profile.id IS NULL OR envelope.id IS NULL OR + attempt.project_id IS DISTINCT FROM NEW.project_id OR + attempt.guide_id IS DISTINCT FROM NEW.guide_id OR + attempt.source_snapshot_id IS DISTINCT FROM NEW.source_snapshot_id OR + attempt.setup_run_id IS DISTINCT FROM NEW.setup_run_id OR + attempt.setup_generation IS DISTINCT FROM NEW.setup_generation OR + attempt.runtime_configuration_hash IS DISTINCT FROM NEW.runtime_configuration_hash OR + ROW(envelope.compilation_attempt_id,envelope.project_id,envelope.guide_id, + envelope.source_snapshot_id,envelope.setup_run_id,envelope.setup_generation, + envelope.runtime_configuration_hash,envelope.profile_id, + envelope.profile_version,envelope.profile_digest) + IS DISTINCT FROM + ROW(NEW.compilation_attempt_id,NEW.project_id,NEW.guide_id, + NEW.source_snapshot_id,NEW.setup_run_id,NEW.setup_generation, + NEW.runtime_configuration_hash,NEW.profile_id,NEW.profile_version, + NEW.profile_digest) OR + profile.project_id::text IS DISTINCT FROM NEW.project_id::text OR + NEW.call_index>=profile.maximum_calls THEN + RAISE EXCEPTION 'invalid setup model usage slot ownership' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.guard_model_usage_effect() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE slot public.checker_model_usage_slots%ROWTYPE; +DECLARE setup_slot public.setup_model_usage_slots%ROWTYPE; +DECLARE checker_envelope public.checker_model_usage_envelopes%ROWTYPE; +DECLARE setup_envelope public.setup_model_usage_envelopes%ROWTYPE; +DECLARE profile public.model_usage_profiles%ROWTYPE; +DECLARE budget public.model_usage_budget_configurations%ROWTYPE; +DECLARE account public.model_usage_budget_accounts%ROWTYPE; +DECLARE parent public.model_usage_effects%ROWTYPE; +DECLARE maximum_dispatch_reservation numeric; +DECLARE maximum_run_reservation numeric; DECLARE exact_cost numeric; +DECLARE run_dispatches bigint; +DECLARE run_pending_dispatches bigint; +DECLARE run_reserved numeric; +DECLARE run_settled numeric; +DECLARE run_request_bytes bigint; +DECLARE run_response_bytes bigint; +DECLARE run_input_tokens bigint; +DECLARE run_output_tokens bigint; DECLARE request_complete boolean; DECLARE response_complete boolean; DECLARE usage_complete boolean; +DECLARE prepare_authority public.audit_events%ROWTYPE; +DECLARE dispatch_authority public.audit_events%ROWTYPE; +DECLARE observation_authority public.audit_events%ROWTYPE; +DECLARE terminal_authority public.audit_events%ROWTYPE; +DECLARE prepare_action text; +DECLARE dispatch_action text; +DECLARE observation_action text; +DECLARE terminal_action text; BEGIN IF TG_OP='DELETE' THEN RAISE EXCEPTION 'checker model call attempts cannot be deleted' USING ERRCODE='55000'; END IF; - SELECT * INTO slot FROM public.checker_model_call_slots WHERE id=NEW.slot_id FOR SHARE; - SELECT * INTO profile FROM public.checker_model_profiles WHERE id=slot.profile_id FOR SHARE; - IF slot.id IS NULL OR profile.id IS NULL OR NEW.request_sha256<>slot.first_request_sha256 THEN + IF NEW.checker_slot_id IS NOT NULL THEN + prepare_action:=CASE WHEN NEW.attempt_number=1 + THEN 'checker.model_call.prepare' + ELSE 'project.model_call.recovery_child.authorize' END; + dispatch_action:='checker.model_call.dispatch'; + observation_action:='checker.model_call.observe'; + terminal_action:='checker.model_call.complete'; + SELECT * INTO slot FROM public.checker_model_usage_slots + WHERE id=NEW.checker_slot_id FOR SHARE; + SELECT * INTO profile FROM public.model_usage_profiles WHERE id=slot.profile_id FOR SHARE; + IF slot.id IS NULL OR profile.id IS NULL OR + NEW.request_sha256<>slot.first_request_sha256 THEN + RAISE EXCEPTION 'invalid checker model call attempt ownership' USING ERRCODE='23514'; + END IF; + SELECT * INTO checker_envelope FROM public.checker_model_usage_envelopes + WHERE id=slot.envelope_id FOR UPDATE; + ELSE + prepare_action:='project.guide_compilation.model.prepare'; + dispatch_action:='project.guide_compilation.model.dispatch'; + observation_action:='project.guide_compilation.model.observe'; + terminal_action:='project.guide_compilation.model.complete'; + SELECT * INTO setup_slot FROM public.setup_model_usage_slots + WHERE id=NEW.setup_slot_id FOR SHARE; + SELECT * INTO profile FROM public.model_usage_profiles + WHERE id=setup_slot.profile_id FOR SHARE; + IF setup_slot.id IS NULL OR profile.id IS NULL OR + NEW.request_sha256<>setup_slot.request_sha256 THEN + RAISE EXCEPTION 'invalid setup model call attempt ownership' USING ERRCODE='23514'; + END IF; + SELECT * INTO setup_envelope FROM public.setup_model_usage_envelopes + WHERE id=setup_slot.envelope_id FOR UPDATE; + END IF; + IF profile.id IS NULL THEN RAISE EXCEPTION 'invalid checker model call attempt ownership' USING ERRCODE='23514'; END IF; - maximum_reservation := profile.maximum_input_tokens::numeric * + SELECT * INTO prepare_authority FROM public.audit_events + WHERE id=NEW.prepared_authorization_evidence_id FOR SHARE; + IF prepare_authority.id IS NULL OR + prepare_authority.event_domain IS DISTINCT FROM 'authority' OR + prepare_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + prepare_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + prepare_authority.action_id IS DISTINCT FROM prepare_action OR + prepare_authority.permission_id IS DISTINCT FROM prepare_action OR + prepare_authority.denial_code IS NOT NULL OR + coalesce(prepare_authority.after_facts->>'resource_context_digest','') + !~ '^sha256:[0-9a-f]{64}$' THEN + RAISE EXCEPTION 'model usage prepare authority is invalid' USING ERRCODE='23514'; + END IF; + maximum_dispatch_reservation := profile.maximum_input_tokens::numeric * greatest(profile.uncached_input_rate,profile.cached_input_rate)::numeric + profile.maximum_output_tokens::numeric * profile.output_rate::numeric; + maximum_run_reservation := profile.maximum_total_input_tokens::numeric * + greatest(profile.uncached_input_rate,profile.cached_input_rate)::numeric + + profile.maximum_total_output_tokens::numeric * profile.output_rate::numeric; request_complete := NEW.request_binding_id IS NOT NULL AND NEW.request_content_id IS NOT NULL AND NEW.request_put_attempt_id IS NOT NULL AND NEW.request_verification_receipt_id IS NOT NULL AND NEW.request_content_sha256=NEW.request_sha256 AND @@ -1485,23 +1836,29 @@ def downgrade() -> None: RAISE EXCEPTION 'initial checker model attempt cannot have a parent' USING ERRCODE='23514'; END IF; ELSE - SELECT * INTO parent FROM public.checker_model_call_attempts + SELECT * INTO parent FROM public.model_usage_effects WHERE id=NEW.parent_attempt_id FOR SHARE; - IF parent.id IS NULL OR parent.slot_id<>NEW.slot_id OR + IF parent.id IS NULL OR + parent.checker_slot_id IS DISTINCT FROM NEW.checker_slot_id OR + parent.setup_slot_id IS DISTINCT FROM NEW.setup_slot_id OR parent.attempt_number+1<>NEW.attempt_number OR parent.attempt_number<>(SELECT max(attempt_number) - FROM public.checker_model_call_attempts WHERE slot_id=NEW.slot_id) OR + FROM public.model_usage_effects WHERE + checker_slot_id IS NOT DISTINCT FROM NEW.checker_slot_id AND + setup_slot_id IS NOT DISTINCT FROM NEW.setup_slot_id) OR NOT (parent.state='known_no_effect' OR (parent.state='known_failed' AND parent.settled_nanodollars IS NOT NULL AND parent.settlement_authorization_evidence_id IS NOT NULL)) THEN RAISE EXCEPTION 'invalid checker model child attempt' USING ERRCODE='23514'; END IF; END IF; - ELSIF ROW(NEW.slot_id,NEW.attempt_number,NEW.internal_call_request_id, + ELSIF ROW(NEW.checker_slot_id,NEW.setup_slot_id,NEW.attempt_number, + NEW.internal_call_request_id, NEW.parent_attempt_id,NEW.request_sha256, NEW.prepared_authorization_evidence_id,NEW.prepared_at) IS DISTINCT FROM - ROW(OLD.slot_id,OLD.attempt_number,OLD.internal_call_request_id, + ROW(OLD.checker_slot_id,OLD.setup_slot_id,OLD.attempt_number, + OLD.internal_call_request_id, OLD.parent_attempt_id,OLD.request_sha256, OLD.prepared_authorization_evidence_id,OLD.prepared_at) THEN RAISE EXCEPTION 'checker model call attempt identity is immutable' USING ERRCODE='55000'; @@ -1549,21 +1906,76 @@ def downgrade() -> None: RAISE EXCEPTION 'invalid prepared checker model call' USING ERRCODE='23514'; END IF; ELSE - SELECT * INTO budget FROM public.checker_model_budget_configurations WHERE + SELECT * INTO dispatch_authority FROM public.audit_events + WHERE id=NEW.dispatch_authorization_evidence_id FOR SHARE; + SELECT * INTO budget FROM public.model_usage_budget_configurations WHERE id=NEW.budget_configuration_id AND configuration_version=NEW.budget_configuration_version AND configuration_digest=NEW.budget_configuration_digest FOR SHARE; - SELECT * INTO account FROM public.checker_model_budget_accounts - WHERE project_id=slot.project_id FOR SHARE; - IF NOT request_complete OR budget.id IS NULL OR budget.project_id<>slot.project_id OR + SELECT * INTO account FROM public.model_usage_budget_accounts + WHERE project_id=coalesce(slot.project_id,setup_slot.project_id) FOR SHARE; + IF dispatch_authority.id IS NULL OR + dispatch_authority.event_domain IS DISTINCT FROM 'authority' OR + dispatch_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + dispatch_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + dispatch_authority.action_id IS DISTINCT FROM dispatch_action OR + dispatch_authority.permission_id IS DISTINCT FROM dispatch_action OR + dispatch_authority.denial_code IS NOT NULL OR + coalesce(dispatch_authority.after_facts->>'resource_context_digest','') + !~ '^sha256:[0-9a-f]{64}$' OR + NOT request_complete OR budget.id IS NULL OR + budget.project_id<>coalesce(slot.project_id,setup_slot.project_id) OR (TG_OP='UPDATE' AND OLD.state='prepared' AND account.current_configuration_id IS DISTINCT FROM budget.id) OR NEW.reserved_nanodollars IS NULL OR - NEW.reserved_nanodollars::numeric<>maximum_reservation OR + NEW.reserved_nanodollars::numeric<>maximum_dispatch_reservation OR NEW.dispatch_authorization_evidence_id IS NULL OR NEW.dispatched_at IS NULL THEN RAISE EXCEPTION 'invalid dispatched checker model call' USING ERRCODE='23514'; END IF; END IF; + SELECT count(*) FILTER (WHERE effect.state<>'prepared'), + count(*) FILTER (WHERE effect.state IN ('dispatching','uncertain')), + coalesce(sum(CASE WHEN effect.state IN ('dispatching','uncertain') + THEN effect.reserved_nanodollars ELSE 0 END),0), + coalesce(sum(effect.settled_nanodollars),0), + coalesce(sum(effect.request_byte_count),0), + coalesce(sum(effect.response_byte_count),0), + coalesce(sum(effect.input_tokens),0), + coalesce(sum(effect.output_tokens),0) + INTO run_dispatches,run_pending_dispatches,run_reserved,run_settled,run_request_bytes, + run_response_bytes,run_input_tokens,run_output_tokens + FROM public.model_usage_effects effect + LEFT JOIN public.checker_model_usage_slots checker_child + ON checker_child.id=effect.checker_slot_id + LEFT JOIN public.setup_model_usage_slots setup_child + ON setup_child.id=effect.setup_slot_id + WHERE effect.id<>NEW.id AND ( + (checker_envelope.id IS NOT NULL AND + checker_child.envelope_id=checker_envelope.id) OR + (setup_envelope.id IS NOT NULL AND setup_child.envelope_id=setup_envelope.id)); + IF NEW.state<>'prepared' THEN + run_dispatches:=run_dispatches+1; + run_request_bytes:=run_request_bytes+coalesce(NEW.request_byte_count,0); + run_response_bytes:=run_response_bytes+coalesce(NEW.response_byte_count,0); + run_input_tokens:=run_input_tokens+coalesce(NEW.input_tokens,0); + run_output_tokens:=run_output_tokens+coalesce(NEW.output_tokens,0); + IF NEW.state IN ('dispatching','uncertain') THEN + run_pending_dispatches:=run_pending_dispatches+1; + run_reserved:=run_reserved+NEW.reserved_nanodollars; + END IF; + run_settled:=run_settled+coalesce(NEW.settled_nanodollars,0); + END IF; + IF run_dispatches>profile.maximum_calls OR + run_reserved+run_settled>maximum_run_reservation OR + run_request_bytes>profile.maximum_total_request_bytes OR + run_response_bytes+run_pending_dispatches*profile.maximum_response_bytes> + profile.maximum_total_response_bytes OR + run_input_tokens+run_pending_dispatches*profile.maximum_input_tokens> + profile.maximum_total_input_tokens OR + run_output_tokens+run_pending_dispatches*profile.maximum_output_tokens> + profile.maximum_total_output_tokens THEN + RAISE EXCEPTION 'model usage run envelope exceeded' USING ERRCODE='23514'; + END IF; IF NEW.state='dispatching' THEN IF NEW.provider_response_id IS NOT NULL OR NEW.uncertainty_digest IS NOT NULL OR NEW.observation_authorization_evidence_id IS NOT NULL OR @@ -1581,7 +1993,18 @@ def downgrade() -> None: RAISE EXCEPTION 'invalid dispatching checker model call' USING ERRCODE='23514'; END IF; ELSIF NEW.state='uncertain' THEN - IF NEW.uncertainty_digest !~ '^sha256:[0-9a-f]{64}$' OR + SELECT * INTO observation_authority FROM public.audit_events + WHERE id=NEW.observation_authorization_evidence_id FOR SHARE; + IF observation_authority.id IS NULL OR + observation_authority.event_domain IS DISTINCT FROM 'authority' OR + observation_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + observation_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + observation_authority.action_id IS DISTINCT FROM observation_action OR + observation_authority.permission_id IS DISTINCT FROM observation_action OR + observation_authority.denial_code IS NOT NULL OR + coalesce(observation_authority.after_facts->>'resource_context_digest','') + !~ '^sha256:[0-9a-f]{64}$' OR + NEW.uncertainty_digest !~ '^sha256:[0-9a-f]{64}$' OR NEW.observation_authorization_evidence_id IS NULL OR NEW.response_binding_id IS NOT NULL OR NEW.response_content_id IS NOT NULL OR NEW.response_put_attempt_id IS NOT NULL OR @@ -1597,7 +2020,18 @@ def downgrade() -> None: RAISE EXCEPTION 'invalid uncertain checker model call' USING ERRCODE='23514'; END IF; ELSIF NEW.state='completed' THEN - IF NEW.provider_response_id IS NULL OR NOT response_complete OR NOT usage_complete OR + SELECT * INTO terminal_authority FROM public.audit_events + WHERE id=NEW.terminal_authorization_evidence_id FOR SHARE; + IF terminal_authority.id IS NULL OR + terminal_authority.event_domain IS DISTINCT FROM 'authority' OR + terminal_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + terminal_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + terminal_authority.action_id IS DISTINCT FROM terminal_action OR + terminal_authority.permission_id IS DISTINCT FROM terminal_action OR + terminal_authority.denial_code IS NOT NULL OR + coalesce(terminal_authority.after_facts->>'resource_context_digest','') + !~ '^sha256:[0-9a-f]{64}$' OR + NEW.provider_response_id IS NULL OR NOT response_complete OR NOT usage_complete OR NEW.settled_nanodollars IS NULL OR NEW.settled_nanodollars::numeric<>exact_cost OR NEW.terminal_authorization_evidence_id IS NULL OR NEW.terminal_at IS NULL OR NEW.no_effect_proof_id IS NOT NULL OR NEW.no_effect_proof_digest IS NOT NULL OR @@ -1606,7 +2040,18 @@ def downgrade() -> None: RAISE EXCEPTION 'invalid completed checker model call' USING ERRCODE='23514'; END IF; ELSIF NEW.state='known_failed' THEN - IF NEW.provider_response_id IS NULL OR NOT response_complete OR + SELECT * INTO terminal_authority FROM public.audit_events + WHERE id=NEW.terminal_authorization_evidence_id FOR SHARE; + IF terminal_authority.id IS NULL OR + terminal_authority.event_domain IS DISTINCT FROM 'authority' OR + terminal_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + terminal_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + terminal_authority.action_id IS DISTINCT FROM terminal_action OR + terminal_authority.permission_id IS DISTINCT FROM terminal_action OR + terminal_authority.denial_code IS NOT NULL OR + coalesce(terminal_authority.after_facts->>'resource_context_digest','') + !~ '^sha256:[0-9a-f]{64}$' OR + NEW.provider_response_id IS NULL OR NOT response_complete OR NEW.terminal_authorization_evidence_id IS NULL OR NEW.terminal_at IS NULL OR NEW.no_effect_proof_id IS NOT NULL OR NOT usage_complete OR NEW.settled_nanodollars IS NULL OR @@ -1615,7 +2060,18 @@ def downgrade() -> None: RAISE EXCEPTION 'invalid known-failed checker model call' USING ERRCODE='23514'; END IF; ELSIF NEW.state='known_no_effect' THEN - IF NEW.provider_response_id IS NOT NULL OR NEW.response_binding_id IS NOT NULL OR + SELECT * INTO terminal_authority FROM public.audit_events + WHERE id=NEW.terminal_authorization_evidence_id FOR SHARE; + IF terminal_authority.id IS NULL OR + terminal_authority.event_domain IS DISTINCT FROM 'authority' OR + terminal_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + terminal_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + terminal_authority.action_id IS DISTINCT FROM terminal_action OR + terminal_authority.permission_id IS DISTINCT FROM terminal_action OR + terminal_authority.denial_code IS NOT NULL OR + coalesce(terminal_authority.after_facts->>'resource_context_digest','') + !~ '^sha256:[0-9a-f]{64}$' OR + NEW.provider_response_id IS NOT NULL OR NEW.response_binding_id IS NOT NULL OR NEW.response_content_id IS NOT NULL OR NEW.response_put_attempt_id IS NOT NULL OR NEW.response_verification_receipt_id IS NOT NULL OR NEW.response_content_sha256 IS NOT NULL OR NEW.response_byte_count IS NOT NULL OR @@ -1676,20 +2132,25 @@ def downgrade() -> None: END $$ """, r""" -CREATE FUNCTION public.reconcile_checker_model_budget() RETURNS trigger +CREATE FUNCTION public.reconcile_model_usage_budget() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ DECLARE target_project uuid; -DECLARE account public.checker_model_budget_accounts%ROWTYPE; +DECLARE account public.model_usage_budget_accounts%ROWTYPE; DECLARE calculated_reserved numeric; DECLARE calculated_settled numeric; BEGIN - IF TG_TABLE_NAME='checker_model_budget_accounts' THEN + IF TG_TABLE_NAME='model_usage_budget_accounts' THEN target_project:=coalesce(NEW.project_id,OLD.project_id); ELSE - SELECT project_id INTO target_project FROM public.checker_model_call_slots - WHERE id=coalesce(NEW.slot_id,OLD.slot_id); + IF coalesce(NEW.checker_slot_id,OLD.checker_slot_id) IS NOT NULL THEN + SELECT project_id INTO target_project FROM public.checker_model_usage_slots + WHERE id=coalesce(NEW.checker_slot_id,OLD.checker_slot_id); + ELSE + SELECT project_id INTO target_project FROM public.setup_model_usage_slots + WHERE id=coalesce(NEW.setup_slot_id,OLD.setup_slot_id); + END IF; END IF; - SELECT * INTO account FROM public.checker_model_budget_accounts + SELECT * INTO account FROM public.model_usage_budget_accounts WHERE project_id=target_project; SELECT coalesce(sum(CASE WHEN a.state IN ('dispatching','uncertain') @@ -1698,9 +2159,12 @@ def downgrade() -> None: (a.state='known_failed' AND a.settled_nanodollars IS NOT NULL) THEN a.settled_nanodollars ELSE 0 END),0) INTO calculated_reserved,calculated_settled - FROM public.checker_model_call_attempts a - JOIN public.checker_model_call_slots s ON s.id=a.slot_id - WHERE s.project_id=target_project; + FROM public.model_usage_effects a + LEFT JOIN public.checker_model_usage_slots checker_slot + ON checker_slot.id=a.checker_slot_id + LEFT JOIN public.setup_model_usage_slots setup_slot + ON setup_slot.id=a.setup_slot_id + WHERE coalesce(checker_slot.project_id,setup_slot.project_id)=target_project; IF account.project_id IS NULL OR account.lifetime_reserved_nanodollars::numeric<>calculated_reserved OR account.lifetime_settled_nanodollars::numeric<>calculated_settled THEN @@ -1710,7 +2174,7 @@ def downgrade() -> None: END $$ """, r""" -CREATE FUNCTION public.guard_checker_model_no_truncate() RETURNS trigger +CREATE FUNCTION public.guard_model_usage_no_truncate() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'checker model ledger cannot be truncated' USING ERRCODE='55000'; END $$ @@ -1719,45 +2183,57 @@ def downgrade() -> None: _TRIGGERS = ( - "CREATE TRIGGER checker_model_profiles_guard BEFORE INSERT OR UPDATE OR DELETE " - "ON public.checker_model_profiles FOR EACH ROW " - "EXECUTE FUNCTION public.guard_checker_model_profile()", - "CREATE TRIGGER checker_model_budget_configurations_guard BEFORE INSERT OR UPDATE OR DELETE " - "ON public.checker_model_budget_configurations FOR EACH ROW " - "EXECUTE FUNCTION public.guard_checker_model_budget_configuration()", - "CREATE TRIGGER checker_model_budget_accounts_guard BEFORE INSERT OR UPDATE OR DELETE " - "ON public.checker_model_budget_accounts FOR EACH ROW " - "EXECUTE FUNCTION public.guard_checker_model_budget_account()", - "CREATE TRIGGER checker_model_call_slots_guard BEFORE INSERT OR UPDATE OR DELETE " - "ON public.checker_model_call_slots FOR EACH ROW " + "CREATE TRIGGER model_usage_profiles_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.model_usage_profiles FOR EACH ROW " + "EXECUTE FUNCTION public.guard_model_usage_profile()", + "CREATE TRIGGER model_usage_budget_configurations_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.model_usage_budget_configurations FOR EACH ROW " + "EXECUTE FUNCTION public.guard_model_usage_budget_configuration()", + "CREATE TRIGGER model_usage_budget_accounts_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.model_usage_budget_accounts FOR EACH ROW " + "EXECUTE FUNCTION public.guard_model_usage_budget_account()", + "CREATE TRIGGER checker_model_usage_slots_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.checker_model_usage_slots FOR EACH ROW " "EXECUTE FUNCTION public.guard_checker_model_call_slot()", - "CREATE TRIGGER checker_model_call_attempts_guard BEFORE INSERT OR UPDATE OR DELETE " - "ON public.checker_model_call_attempts FOR EACH ROW " - "EXECUTE FUNCTION public.guard_checker_model_call_attempt()", - "CREATE TRIGGER checker_model_call_recoveries_guard BEFORE INSERT OR UPDATE OR DELETE " - "ON public.checker_model_call_recoveries FOR EACH ROW " + "CREATE TRIGGER checker_model_usage_envelopes_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.checker_model_usage_envelopes FOR EACH ROW " + "EXECUTE FUNCTION public.guard_checker_model_usage_envelope()", + "CREATE TRIGGER setup_model_usage_envelopes_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.setup_model_usage_envelopes FOR EACH ROW " + "EXECUTE FUNCTION public.guard_setup_model_usage_envelope()", + "CREATE TRIGGER setup_model_usage_slots_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.setup_model_usage_slots FOR EACH ROW " + "EXECUTE FUNCTION public.guard_setup_model_usage_slot()", + "CREATE TRIGGER model_usage_effects_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.model_usage_effects FOR EACH ROW " + "EXECUTE FUNCTION public.guard_model_usage_effect()", + "CREATE TRIGGER checker_model_usage_recoveries_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.checker_model_usage_recoveries FOR EACH ROW " "EXECUTE FUNCTION public.guard_checker_model_recovery()", "CREATE CONSTRAINT TRIGGER checker_model_budget_account_reconcile " - "AFTER INSERT OR UPDATE OR DELETE ON public.checker_model_budget_accounts " + "AFTER INSERT OR UPDATE OR DELETE ON public.model_usage_budget_accounts " "DEFERRABLE INITIALLY DEFERRED FOR EACH ROW " - "EXECUTE FUNCTION public.reconcile_checker_model_budget()", + "EXECUTE FUNCTION public.reconcile_model_usage_budget()", "CREATE CONSTRAINT TRIGGER checker_model_call_attempt_reconcile " - "AFTER INSERT OR UPDATE OR DELETE ON public.checker_model_call_attempts " + "AFTER INSERT OR UPDATE OR DELETE ON public.model_usage_effects " "DEFERRABLE INITIALLY DEFERRED FOR EACH ROW " - "EXECUTE FUNCTION public.reconcile_checker_model_budget()", + "EXECUTE FUNCTION public.reconcile_model_usage_budget()", *( "CREATE TRIGGER " + table + "_no_truncate BEFORE TRUNCATE ON public." + table - + " FOR EACH STATEMENT EXECUTE FUNCTION public.guard_checker_model_no_truncate()" + + " FOR EACH STATEMENT EXECUTE FUNCTION public.guard_model_usage_no_truncate()" for table in ( - "checker_model_profiles", - "checker_model_budget_configurations", - "checker_model_budget_accounts", - "checker_model_call_slots", - "checker_model_call_attempts", - "checker_model_call_recoveries", + "model_usage_profiles", + "model_usage_budget_configurations", + "model_usage_budget_accounts", + "checker_model_usage_slots", + "checker_model_usage_envelopes", + "setup_model_usage_envelopes", + "setup_model_usage_slots", + "model_usage_effects", + "checker_model_usage_recoveries", ) ), ) diff --git a/backend/app/adapters/auth/__init__.py b/backend/app/adapters/auth/__init__.py index 463f687cb..f3aec2df4 100644 --- a/backend/app/adapters/auth/__init__.py +++ b/backend/app/adapters/auth/__init__.py @@ -138,7 +138,7 @@ def checker_registry_authorization( def checker_model_authorization(session: AsyncSession, context: AuthorizationContext): """Compose exact project-model authority using the existing AUTH/PREP graph.""" - from app.modules.authorization.checker_model_authorization import CheckerModelAuthorization + from app.modules.authorization.model_usage_authorization import CheckerModelAuthorization repository = AdminAuthorizationRepository(session) revalidate = None diff --git a/backend/app/adapters/checkers/__init__.py b/backend/app/adapters/checkers/__init__.py index dc2242884..04d31ae70 100644 --- a/backend/app/adapters/checkers/__init__.py +++ b/backend/app/adapters/checkers/__init__.py @@ -180,9 +180,9 @@ def external_checker_registry_reader(session): def model_profile_reader(session): """Resolve exact immutable project model profiles without mutation authority.""" - from app.modules.checkers.model_calls.profiles import ModelProfileReader + from app.adapters.model_usage import model_profile_reader as shared_reader - return ModelProfileReader(session) + return shared_reader(session) def external_checker_registry_service(session, authority): @@ -338,6 +338,7 @@ def checker_retry_service(session, context, *, cursor_secret=None): ) from app.adapters.outbox import outbox_append from app.adapters.tasks import evaluation_task_guard + from app.adapters.model_usage import model_recovery_sources from app.modules.checkers.checker_retry import CheckerRetryService from app.modules.checkers.execution_coordination import EvaluationCoordinator @@ -355,6 +356,7 @@ def checker_retry_service(session, context, *, cursor_secret=None): ), events=outbox_append(session), audit=checker_retry_audit(session), + model_recovery=model_recovery_sources(session), ) @@ -429,7 +431,8 @@ def post_submission_executor(*, sessions, materialization): def checker_output_reservations(session): """Resolve exact call custody with TASK-before-CHECKERS request locking.""" from app.adapters.tasks import evaluation_task_guard - from app.modules.checkers.model_calls.custody import ( + from app.adapters.model_usage import model_evidence_custody, model_recovery_sources + from app.modules.checkers.model_usage_custody import ( CheckerOutputReservations, ModelCallCurrentExecution, ) @@ -437,8 +440,11 @@ def checker_output_reservations(session): tasks = evaluation_task_guard(session) return CheckerOutputReservations( session, - current=ModelCallCurrentExecution(session, tasks=tasks), + current=ModelCallCurrentExecution( + session, tasks=tasks, recovery=model_recovery_sources(session) + ), tasks=tasks, + model=model_evidence_custody(session), ) @@ -446,13 +452,20 @@ def model_call_service(session, authority): """Compose durable model spending with exact TASK and ART owner ports.""" from app.adapters.tasks import evaluation_task_guard from app.adapters.artifacts import checker_model_evidence - from app.modules.checkers.model_calls.custody import ModelCallCurrentExecution - from app.modules.checkers.model_calls.service import ModelCallService + from app.adapters.model_usage import ( + model_call_service as shared_model_call_service, + model_recovery_sources, + ) + from app.modules.checkers.model_usage_custody import ModelCallCurrentExecution - return ModelCallService( + return shared_model_call_service( session, authority=authority, - current_execution=ModelCallCurrentExecution(session, tasks=evaluation_task_guard(session)), + current_execution=ModelCallCurrentExecution( + session, + tasks=evaluation_task_guard(session), + recovery=model_recovery_sources(session), + ), evidence=checker_model_evidence(session), ) @@ -460,13 +473,20 @@ def model_call_service(session, authority): def model_invocation_grants(session, authority): """Compose short-lived image capabilities without creating provider effects.""" from app.adapters.tasks import evaluation_task_guard - from app.modules.checkers.model_calls.custody import ModelCallCurrentExecution - from app.modules.checkers.model_calls.grants import ModelInvocationGrants + from app.adapters.model_usage import ( + model_invocation_grants as shared_model_invocation_grants, + model_recovery_sources, + ) + from app.modules.checkers.model_usage_custody import ModelCallCurrentExecution - return ModelInvocationGrants( + return shared_model_invocation_grants( session, authority=authority, - current=ModelCallCurrentExecution(session, tasks=evaluation_task_guard(session)), + current_execution=ModelCallCurrentExecution( + session, + tasks=evaluation_task_guard(session), + recovery=model_recovery_sources(session), + ), ) diff --git a/backend/app/adapters/checkers/model_execution.py b/backend/app/adapters/checkers/model_execution.py index 223ad8385..ae898818f 100644 --- a/backend/app/adapters/checkers/model_execution.py +++ b/backend/app/adapters/checkers/model_execution.py @@ -1,7 +1,7 @@ """Compose optional project-image model callbacks into the sole POST execution path.""" from app.interfaces.external_services import ExternalServiceUnavailableError -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelCallExecutionFacts, ModelCallUnavailable, ModelPaidSlotKey, diff --git a/backend/app/adapters/checkers/model_proxy.py b/backend/app/adapters/checkers/model_proxy.py index 619ab3a7a..a0e5a9e83 100644 --- a/backend/app/adapters/checkers/model_proxy.py +++ b/backend/app/adapters/checkers/model_proxy.py @@ -21,7 +21,7 @@ CheckerModelResponseReference, ) from app.interfaces.checker_model_proxy import ModelProxyRequest, ModelProxyReply -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelCallEffectAuthorityFacts, ModelCallEvidence, ModelCallUsage, diff --git a/backend/app/adapters/checkers/model_runtime.py b/backend/app/adapters/checkers/model_runtime.py index 81cb2e8bf..8311fa912 100644 --- a/backend/app/adapters/checkers/model_runtime.py +++ b/backend/app/adapters/checkers/model_runtime.py @@ -25,7 +25,7 @@ OpenAIResponsesSettings, ) from app.adapters.checkers.model_proxy_transport import ModelProxyTransport -from app.modules.checkers.api.model_calls import ModelCallUnavailable, ProjectModelProfile +from app.modules.model_usage.api import ModelCallUnavailable, ProjectModelProfile @asynccontextmanager diff --git a/backend/app/adapters/model_usage/__init__.py b/backend/app/adapters/model_usage/__init__.py new file mode 100644 index 000000000..329f9b307 --- /dev/null +++ b/backend/app/adapters/model_usage/__init__.py @@ -0,0 +1,61 @@ +"""MODEL-USAGE composition root for shared durable ledger implementations.""" + + +def model_profile_reader(session): + from app.modules.model_usage.profiles import ModelProfileReader + + return ModelProfileReader(session) + + +def model_call_service(session, *, authority, current_execution, evidence): + from app.modules.model_usage.service import ModelCallService + + return ModelCallService( + session, + authority=authority, + current_execution=current_execution, + evidence=evidence, + ) + + +def model_invocation_grants(session, *, authority, current_execution): + from app.modules.model_usage.grants import ModelInvocationGrants + + return ModelInvocationGrants( + session, + authority=authority, + current=current_execution, + ) + + +def model_evidence_custody(session): + from app.modules.model_usage.owner_ports import ModelEvidenceCustody + + return ModelEvidenceCustody(session) + + +def model_recovery_sources(session): + from app.modules.model_usage.owner_ports import ModelRecoverySources + + return ModelRecoverySources(session) + + +def setup_model_usage_service(session, *, authority, evidence, owner): + from app.modules.model_usage.setup import SetupModelUsageService + + return SetupModelUsageService( + session, + authority=authority, + evidence=evidence, + owner=owner, + ) + + +__all__ = ( + "model_call_service", + "model_evidence_custody", + "model_invocation_grants", + "model_profile_reader", + "model_recovery_sources", + "setup_model_usage_service", +) diff --git a/backend/app/api/deps/checker_models.py b/backend/app/api/deps/checker_models.py index d98a870f5..f9bb789cc 100644 --- a/backend/app/api/deps/checker_models.py +++ b/backend/app/api/deps/checker_models.py @@ -23,7 +23,7 @@ from app.core.api_controls import StructuredHTTPException, parse_idempotency_key, request_ids from app.core.hashing import canonical_json_hash from app.db.session import get_db_session -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelBudgetProvisioningFacts, ModelCallBudgetUnavailable, ModelCallAdministrationPort, diff --git a/backend/app/db/models.py b/backend/app/db/models.py index ea973671e..e2bb7a027 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -124,12 +124,15 @@ from app.modules.tasks.revision.models import TaskRevisionPreparation, TaskRevisionExpiration, TaskRevisionExhaustion # noqa: F401 -from app.modules.checkers.model_calls.models import ( # noqa: F401 - CheckerModelProfile, - CheckerModelBudgetAccount, - CheckerModelBudgetConfiguration, - CheckerModelCallSlot, - CheckerModelCallAttempt, - CheckerModelCallRecovery, +from app.modules.model_usage.models import ( # noqa: F401 + ModelUsageProfile, + ModelUsageBudgetAccount, + ModelUsageBudgetConfiguration, + CheckerModelUsageEnvelope, + CheckerModelUsageSlot, + SetupModelUsageEnvelope, + SetupModelUsageSlot, + ModelUsageEffect, + CheckerModelUsageRecovery, CheckerModelInvocationGrant, ) diff --git a/backend/app/modules/artifacts/checker_model_evidence.py b/backend/app/modules/artifacts/checker_model_evidence.py index ab6aef07c..6f4cc1795 100644 --- a/backend/app/modules/artifacts/checker_model_evidence.py +++ b/backend/app/modules/artifacts/checker_model_evidence.py @@ -13,7 +13,7 @@ ArtifactVerificationJob, ArtifactVerificationReceipt, ) -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelCallEvidence, ModelCallUnavailable, PreparedModelCallAttempt, diff --git a/backend/app/modules/artifacts/checker_outputs.py b/backend/app/modules/artifacts/checker_outputs.py index a23cb0c20..93230be53 100644 --- a/backend/app/modules/artifacts/checker_outputs.py +++ b/backend/app/modules/artifacts/checker_outputs.py @@ -21,7 +21,7 @@ from app.interfaces.artifacts import ArtifactStore, ArtifactByteRange, ArtifactIntegrityError from app.modules.artifacts.checker_output_custody import select_checker_output from app.modules.artifacts.models import ArtifactVerificationJob, ArtifactReplica -from app.modules.checkers.api.model_calls import ModelCallEvidence +from app.modules.model_usage.api import ModelCallEvidence from app.modules.artifacts.preparation import ArtifactPreparationService from app.modules.artifacts.schemas import ( ArtifactInternalAuthority, diff --git a/backend/app/modules/artifacts/models.py b/backend/app/modules/artifacts/models.py index 954a0fcd4..e30c8387d 100644 --- a/backend/app/modules/artifacts/models.py +++ b/backend/app/modules/artifacts/models.py @@ -1444,7 +1444,7 @@ class ArtifactPutAttempt(Base): ForeignKey("checker_runs.id", ondelete="RESTRICT"), index=True ) model_call_attempt_id: Mapped[str | None] = mapped_column( - Uuid(as_uuid=False), ForeignKey("checker_model_call_attempts.id", ondelete="RESTRICT"), index=True + Uuid(as_uuid=False), ForeignKey("model_usage_effects.id", ondelete="RESTRICT"), index=True ) submission_id: Mapped[str | None] = mapped_column(Uuid(as_uuid=False), index=True) submission_version: Mapped[int | None] = mapped_column(Integer) @@ -1878,7 +1878,7 @@ class ArtifactOperationReceipt(Base): ForeignKey("checker_runs.id", ondelete="RESTRICT"), index=True ) model_call_attempt_id: Mapped[str | None] = mapped_column( - Uuid(as_uuid=False), ForeignKey("checker_model_call_attempts.id", ondelete="RESTRICT"), index=True + Uuid(as_uuid=False), ForeignKey("model_usage_effects.id", ondelete="RESTRICT"), index=True ) logical_role: Mapped[str | None] = mapped_column(String(100)) replica_id: Mapped[str] = mapped_column( diff --git a/backend/app/modules/authorization/admin_schemas.py b/backend/app/modules/authorization/admin_schemas.py index 9d9a6159d..6d526baf9 100644 --- a/backend/app/modules/authorization/admin_schemas.py +++ b/backend/app/modules/authorization/admin_schemas.py @@ -35,7 +35,7 @@ class PermissionDefinitionsResponse(BaseModel): model_config = _STRICT items: tuple[PermissionDefinitionResponse, ...] - total: Literal[91] + total: Literal[95] class AdminRoleDefinitionResponse(BaseModel): diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 2f143e7e7..d9b2cc367 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -36,11 +36,13 @@ class PermissionId(StrEnum): CHECKER_MODEL_CALL_OBSERVE = "checker.model_call.observe" CHECKER_MODEL_CALL_COMPLETE = "checker.model_call.complete" CHECKER_MODEL_CALL_GRANT = "checker.model_call.grant" + PROJECT_GUIDE_COMPILATION_MODEL_PREPARE = "project.guide_compilation.model.prepare" + PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH = "project.guide_compilation.model.dispatch" + PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE = "project.guide_compilation.model.observe" + PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE = "project.guide_compilation.model.complete" PROJECT_MODEL_PROFILE_MANAGE = "project.model_profile.manage" PROJECT_MODEL_BUDGET_MANAGE = "project.model_budget.manage" - PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE = ( - "project.model_call.recovery_child.authorize" - ) + PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE = "project.model_call.recovery_child.authorize" OUTBOX_DISPATCH = "outbox.dispatch" ACTOR_SERVICE_PROVISION = "actor.service.provision" ADMIN_ROLE_READ = "admin_role.read" @@ -173,11 +175,13 @@ class ActionId(StrEnum): CHECKER_MODEL_CALL_OBSERVE = "checker.model_call.observe" CHECKER_MODEL_CALL_COMPLETE = "checker.model_call.complete" CHECKER_MODEL_CALL_GRANT = "checker.model_call.grant" + PROJECT_GUIDE_COMPILATION_MODEL_PREPARE = "project.guide_compilation.model.prepare" + PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH = "project.guide_compilation.model.dispatch" + PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE = "project.guide_compilation.model.observe" + PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE = "project.guide_compilation.model.complete" PROJECT_MODEL_PROFILE_PROVISION = "project.model_profile.provision" PROJECT_MODEL_BUDGET_CONFIGURE = "project.model_budget.configure" - PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE = ( - "project.model_call.recovery_child.authorize" - ) + PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE = "project.model_call.recovery_child.authorize" OUTBOX_DISPATCH = "outbox.dispatch" ACTOR_SERVICE_PROVISION = "actor.service.provision" PROJECT_CONTRIBUTOR_CANDIDATE_LIST = "project.contributor_candidate.list" @@ -310,46 +314,75 @@ class ActionId(StrEnum): PROJECT_SUBMISSION_BUNDLE_PREPARATION_LIST = "project.submission_bundle_preparation.list" CONTRIBUTION_POLICY_LIST = "contribution.policy.list" -CONTRIBUTION_READ_ACTIONS = frozenset({ActionId.CONTRIBUTION_READ_SELF, ActionId.CONTRIBUTION_READ_PROJECT}) + +CONTRIBUTION_READ_ACTIONS = frozenset( + {ActionId.CONTRIBUTION_READ_SELF, ActionId.CONTRIBUTION_READ_PROJECT} +) # Own-work reads under a current exact-project Submitter grant. -CONTRIBUTOR_WORK_READ_ACTIONS = frozenset({ - ActionId.ACTOR_ASSIGNMENT_LIST, - ActionId.ACTOR_SUBMISSION_LIST, - ActionId.ACTOR_WORK_SUMMARY_READ, - ActionId.TASK_SUBMISSION_BUNDLE_PREPARATION_LIST, - ActionId.TASK_SUBMISSION_BUNDLE_PREPARATION_READ, -}) +CONTRIBUTOR_WORK_READ_ACTIONS = frozenset( + { + ActionId.ACTOR_ASSIGNMENT_LIST, + ActionId.ACTOR_SUBMISSION_LIST, + ActionId.ACTOR_WORK_SUMMARY_READ, + ActionId.TASK_SUBMISSION_BUNDLE_PREPARATION_LIST, + ActionId.TASK_SUBMISSION_BUNDLE_PREPARATION_READ, + } +) CONTRIBUTOR_READ_ACTIONS = CONTRIBUTOR_WORK_READ_ACTIONS | {ActionId.ACTOR_PROJECT_LIST} # Project-wide Project Manager reads; the Finance policy version list shares their evaluator. -PROJECT_GUIDE_READ_ACTIONS = frozenset({ActionId.PROJECT_GUIDE_LIST, ActionId.PROJECT_GUIDE_READ, - ActionId.PROJECT_GUIDE_ACTIVATION_READINESS_READ, ActionId.PROJECT_GUIDE_DOCUMENT_READ}) -PROJECT_WORK_READ_ACTIONS = frozenset({ActionId.PROJECT_SUBMISSION_LIST, ActionId.PROJECT_ASSIGNMENT_LIST, - ActionId.PROJECT_SUMMARY_READ, ActionId.PROJECT_TASK_IMPORT_BATCH_LIST, - ActionId.PROJECT_SUBMISSION_BUNDLE_PREPARATION_LIST}) -PROJECT_MANAGEMENT_READ_ACTIONS = PROJECT_GUIDE_READ_ACTIONS | PROJECT_WORK_READ_ACTIONS | {ActionId.CONTRIBUTION_POLICY_LIST} +PROJECT_GUIDE_READ_ACTIONS = frozenset( + { + ActionId.PROJECT_GUIDE_LIST, + ActionId.PROJECT_GUIDE_READ, + ActionId.PROJECT_GUIDE_ACTIVATION_READINESS_READ, + ActionId.PROJECT_GUIDE_DOCUMENT_READ, + } +) +PROJECT_WORK_READ_ACTIONS = frozenset( + { + ActionId.PROJECT_SUBMISSION_LIST, + ActionId.PROJECT_ASSIGNMENT_LIST, + ActionId.PROJECT_SUMMARY_READ, + ActionId.PROJECT_TASK_IMPORT_BATCH_LIST, + ActionId.PROJECT_SUBMISSION_BUNDLE_PREPARATION_LIST, + } +) +PROJECT_MANAGEMENT_READ_ACTIONS = ( + PROJECT_GUIDE_READ_ACTIONS | PROJECT_WORK_READ_ACTIONS | {ActionId.CONTRIBUTION_POLICY_LIST} +) -HISTORY_READ_ACTIONS = frozenset({ - ActionId.TASK_SUBMISSION_LIST, - ActionId.SUBMISSION_READ, - ActionId.SUBMISSION_CHECKER_RUN_LIST, - ActionId.CHECKER_RUN_READ, - ActionId.PROJECT_TASK_SUBMISSION_LIST, - ActionId.PROJECT_SUBMISSION_READ, - ActionId.PROJECT_SUBMISSION_CHECKER_RUN_LIST, - ActionId.PROJECT_CHECKER_RUN_READ, -}) +HISTORY_READ_ACTIONS = frozenset( + { + ActionId.TASK_SUBMISSION_LIST, + ActionId.SUBMISSION_READ, + ActionId.SUBMISSION_CHECKER_RUN_LIST, + ActionId.CHECKER_RUN_READ, + ActionId.PROJECT_TASK_SUBMISSION_LIST, + ActionId.PROJECT_SUBMISSION_READ, + ActionId.PROJECT_SUBMISSION_CHECKER_RUN_LIST, + ActionId.PROJECT_CHECKER_RUN_READ, + } +) + +TASK_LOCKED_CONTEXT_READ_ACTIONS = frozenset( + { + ActionId.PROJECT_TASK_LOCKED_CONTEXT_READ, + ActionId.OPERATIONS_TASK_LOCKED_CONTEXT_READ, + ActionId.AUDIT_TASK_LOCKED_CONTEXT_READ, + } +) +TASK_CONCEALED_READ_ACTIONS = TASK_LOCKED_CONTEXT_READ_ACTIONS | frozenset( + { + ActionId.TASK_GUIDE_READ, + ActionId.TASK_WORK_CONTEXT_READ, + ActionId.AUDIT_TASK_EVIDENCE_READ, + ActionId.TASK_READ, + ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, + ActionId.PROJECT_TASK_READ, + ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, + } +) -TASK_LOCKED_CONTEXT_READ_ACTIONS = frozenset({ - ActionId.PROJECT_TASK_LOCKED_CONTEXT_READ, ActionId.OPERATIONS_TASK_LOCKED_CONTEXT_READ, - ActionId.AUDIT_TASK_LOCKED_CONTEXT_READ, -}) -TASK_CONCEALED_READ_ACTIONS = TASK_LOCKED_CONTEXT_READ_ACTIONS | frozenset({ - ActionId.TASK_GUIDE_READ, - ActionId.TASK_WORK_CONTEXT_READ, - ActionId.AUDIT_TASK_EVIDENCE_READ, - ActionId.TASK_READ, ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, - ActionId.PROJECT_TASK_READ, ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, -}) @unique class ActionOwner(StrEnum): @@ -467,180 +500,698 @@ def _active( ACTION_DEFINITIONS = ( - _active(ActionId.ACTOR_PROJECT_LIST, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.PILOT_READ_2), + _active( + ActionId.ACTOR_PROJECT_LIST, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.PILOT_READ_2 + ), *( _active(action, PermissionId.SUBMISSION_READ_OWN, ActionOwner.PILOT_READ_2) for action in sorted(CONTRIBUTOR_WORK_READ_ACTIONS) ), - *(_active(a, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.PILOT_READ_3) for a in sorted(PROJECT_GUIDE_READ_ACTIONS)), - *(_active(a, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_READ_3) for a in sorted(PROJECT_WORK_READ_ACTIONS)), - _active(ActionId.CONTRIBUTION_POLICY_LIST, PermissionId.COMPENSATION_POLICY_MANAGE, ActionOwner.PILOT_READ_3), - _active(ActionId.CONTRIBUTION_READ_SELF, PermissionId.CONTRIBUTION_READ_SELF, ActionOwner.PILOT_10), - _active(ActionId.CONTRIBUTION_READ_PROJECT, PermissionId.CONTRIBUTION_READ_PROJECT, ActionOwner.PILOT_10), - _active(ActionId.TASK_POST_SUBMIT_ROUTE, PermissionId.TASK_POST_SUBMIT_ROUTE, ActionOwner.ARCH_04E2), - _active(ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, ActionOwner.ARCH_03C1), + *( + _active(a, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.PILOT_READ_3) + for a in sorted(PROJECT_GUIDE_READ_ACTIONS) + ), + *( + _active(a, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_READ_3) + for a in sorted(PROJECT_WORK_READ_ACTIONS) + ), + _active( + ActionId.CONTRIBUTION_POLICY_LIST, + PermissionId.COMPENSATION_POLICY_MANAGE, + ActionOwner.PILOT_READ_3, + ), + _active( + ActionId.CONTRIBUTION_READ_SELF, PermissionId.CONTRIBUTION_READ_SELF, ActionOwner.PILOT_10 + ), + _active( + ActionId.CONTRIBUTION_READ_PROJECT, + PermissionId.CONTRIBUTION_READ_PROJECT, + ActionOwner.PILOT_10, + ), + _active( + ActionId.TASK_POST_SUBMIT_ROUTE, PermissionId.TASK_POST_SUBMIT_ROUTE, ActionOwner.ARCH_04E2 + ), + _active( + ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, + PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, + ActionOwner.ARCH_03C1, + ), _active(ActionId.TASK_REVISION_EXPIRE, PermissionId.TASK_REVISION_EXPIRE, ActionOwner.PILOT_08), - _active(ActionId.TASK_REVISION_RECOVER, PermissionId.TASK_REVISION_RECOVER, ActionOwner.PILOT_08), + _active( + ActionId.TASK_REVISION_RECOVER, PermissionId.TASK_REVISION_RECOVER, ActionOwner.PILOT_08 + ), _active(ActionId.OUTBOX_DISPATCH, PermissionId.OUTBOX_DISPATCH, ActionOwner.AUTH_OUTBOX_01), _active( ActionId.ACTOR_PROFILE_READ_SELF, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.AUTH_07B ), - _active(ActionId.ACTOR_PROFILE_UPDATE_SELF, PermissionId.ACTOR_PROFILE_UPDATE_SELF, ActionOwner.AUTH_07B), - _active(ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, PermissionId.ADMIN_ROLE_READ, ActionOwner.AUTH_08), - _active(ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ, PermissionId.ADMIN_ROLE_READ, ActionOwner.AUTH_08), + _active( + ActionId.ACTOR_PROFILE_UPDATE_SELF, + PermissionId.ACTOR_PROFILE_UPDATE_SELF, + ActionOwner.AUTH_07B, + ), + _active( + ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, + PermissionId.ADMIN_ROLE_READ, + ActionOwner.AUTH_08, + ), + _active( + ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ, + PermissionId.ADMIN_ROLE_READ, + ActionOwner.AUTH_08, + ), _active(ActionId.ADMIN_ROLE_GRANT_LIST, PermissionId.ADMIN_ROLE_READ, ActionOwner.AUTH_08), - _active(ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ, PermissionId.ADMIN_ROLE_READ, ActionOwner.AUTH_08), + _active( + ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ, + PermissionId.ADMIN_ROLE_READ, + ActionOwner.AUTH_08, + ), _active(ActionId.ADMIN_ROLE_GRANT_ISSUE, PermissionId.ADMIN_ROLE_GRANT, ActionOwner.AUTH_08), _active(ActionId.ADMIN_ROLE_GRANT_REVOKE, PermissionId.ADMIN_ROLE_REVOKE, ActionOwner.AUTH_08), - _active(ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, PermissionId.ADMIN_ROLE_GRANT, ActionOwner.AUTH_08), + _active( + ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, PermissionId.ADMIN_ROLE_GRANT, ActionOwner.AUTH_08 + ), _active(ActionId.ACTOR_PROFILE_READ, PermissionId.ACTOR_PROFILE_READ_ANY, ActionOwner.AUTH_09C), - _active(ActionId.ACTOR_PROFILE_SUSPEND, PermissionId.ACTOR_PROFILE_SUSPEND, ActionOwner.AUTH_09D_A), - _active(ActionId.ACTOR_PROFILE_REACTIVATE, PermissionId.ACTOR_PROFILE_REACTIVATE, ActionOwner.AUTH_09D_A), - _active(ActionId.ACTOR_PROFILE_DEACTIVATE, PermissionId.ACTOR_PROFILE_DEACTIVATE, ActionOwner.AUTH_09D_A), - _active(ActionId.ACTOR_IDENTITY_LINK_READ, PermissionId.ACTOR_IDENTITY_LINK_READ, ActionOwner.AUTH_09C), - _active(ActionId.ACTOR_IDENTITY_LINK_REVOKE, PermissionId.ACTOR_IDENTITY_LINK_REVOKE, ActionOwner.AUTH_09D_B), - _active(ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, PermissionId.ACTOR_IDENTITY_LINK_REACTIVATE, ActionOwner.AUTH_09D_B), - _active(ActionId.ACTOR_SERVICE_PROVISION, PermissionId.ACTOR_SERVICE_PROVISION, ActionOwner.AUTH_09B), - _active(ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, PermissionId.PROJECT_ROLE_GRANT_MANAGE, ActionOwner.AUTH_10B), - _active(ActionId.PROJECT_ROLE_GRANT_LIST, PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B), - _active(ActionId.PROJECT_ROLE_GRANT_READ, PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B), - _active(ActionId.PROJECT_ROLE_GRANT_ISSUE, PermissionId.PROJECT_ROLE_GRANT_MANAGE, ActionOwner.AUTH_10C), - _active(ActionId.PROJECT_ROLE_GRANT_REVOKE, PermissionId.PROJECT_ROLE_GRANT_MANAGE, ActionOwner.AUTH_10C), + _active( + ActionId.ACTOR_PROFILE_SUSPEND, PermissionId.ACTOR_PROFILE_SUSPEND, ActionOwner.AUTH_09D_A + ), + _active( + ActionId.ACTOR_PROFILE_REACTIVATE, + PermissionId.ACTOR_PROFILE_REACTIVATE, + ActionOwner.AUTH_09D_A, + ), + _active( + ActionId.ACTOR_PROFILE_DEACTIVATE, + PermissionId.ACTOR_PROFILE_DEACTIVATE, + ActionOwner.AUTH_09D_A, + ), + _active( + ActionId.ACTOR_IDENTITY_LINK_READ, + PermissionId.ACTOR_IDENTITY_LINK_READ, + ActionOwner.AUTH_09C, + ), + _active( + ActionId.ACTOR_IDENTITY_LINK_REVOKE, + PermissionId.ACTOR_IDENTITY_LINK_REVOKE, + ActionOwner.AUTH_09D_B, + ), + _active( + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + PermissionId.ACTOR_IDENTITY_LINK_REACTIVATE, + ActionOwner.AUTH_09D_B, + ), + _active( + ActionId.ACTOR_SERVICE_PROVISION, PermissionId.ACTOR_SERVICE_PROVISION, ActionOwner.AUTH_09B + ), + _active( + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + PermissionId.PROJECT_ROLE_GRANT_MANAGE, + ActionOwner.AUTH_10B, + ), + _active( + ActionId.PROJECT_ROLE_GRANT_LIST, PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B + ), + _active( + ActionId.PROJECT_ROLE_GRANT_READ, PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B + ), + _active( + ActionId.PROJECT_ROLE_GRANT_ISSUE, + PermissionId.PROJECT_ROLE_GRANT_MANAGE, + ActionOwner.AUTH_10C, + ), + _active( + ActionId.PROJECT_ROLE_GRANT_REVOKE, + PermissionId.PROJECT_ROLE_GRANT_MANAGE, + ActionOwner.AUTH_10C, + ), _active(ActionId.PROJECT_READ, PermissionId.PROJECT_READ, ActionOwner.AUTH_11B), - _active(ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.AUTH_11B), - _active(ActionId.PROJECT_SETUP_RUN_READ, PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, ActionOwner.AUTH_11C1), - _active(ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, ActionOwner.AUTH_11C1), - _active(ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, ActionOwner.AUTH_11C1), - _active(ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, PermissionId.PROJECT_EFFECTIVE_POLICY_READ, ActionOwner.AUTH_11C1), - _active(ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, PermissionId.PROJECT_EFFECTIVE_POLICY_READ, ActionOwner.AUTH_11C1), - _active(ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, PermissionId.PROJECT_EFFECTIVE_POLICY_READ, ActionOwner.AUTH_11C2), - _active(ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, PermissionId.PROJECT_EFFECTIVE_POLICY_READ, ActionOwner.AUTH_11C2), + _active( + ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, + PermissionId.ACTOR_PROFILE_READ_SELF, + ActionOwner.AUTH_11B, + ), + _active( + ActionId.PROJECT_SETUP_RUN_READ, + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + ActionOwner.AUTH_11C1, + ), + _active( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + ActionOwner.AUTH_11C1, + ), + _active( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + ActionOwner.AUTH_11C1, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C1, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C1, + ), + _active( + ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C2, + ), + _active( + ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C2, + ), _active(ActionId.PROJECT_ACTIVE_GUIDE_READ, PermissionId.PROJECT_READ, ActionOwner.AUTH_11C2), _active(ActionId.PROJECT_CREATE, PermissionId.PROJECT_CREATE, ActionOwner.AUTH_12C), _active(ActionId.PROJECT_GUIDE_CREATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D), _active(ActionId.PROJECT_GUIDE_UPDATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D), - _active(ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D), - _active(ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, ActionOwner.AUTH_12I), - _active(ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionOwner.AUTH_12I), - _active(ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionOwner.AUTH_12I), - _active(ActionId.PROJECT_REVIEW_POLICY_UPDATE, PermissionId.PROJECT_REVIEW_POLICY_MANAGE, ActionOwner.XINT_003_02B), - _active(ActionId.PROJECT_REVISION_POLICY_UPDATE, PermissionId.PROJECT_REVIEW_POLICY_MANAGE, ActionOwner.XINT_003_02B), - _active(ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12E), - _active(ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12E), - _active(ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12E), - _active(ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12F2), - _active(ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12F3), - _active(ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12F2), - _active(ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12F4), - _active(ActionId.PROJECT_GUIDE_COMPILATION_CORRECTION_REQUEST, PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, ActionOwner.AUTH_12F4), - _active(ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12F4), - _active(ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12G), - _active(ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12G), - _active(ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12G), - _active(ActionId.PROJECT_SETUP_RUN_UPDATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12B2), - _active(ActionId.PROJECT_GUIDE_ACTIVATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12H), - _active(ActionId.TASK_SUBMISSION_LIST, PermissionId.SUBMISSION_READ_OWN, ActionOwner.TASK_CHECKER_CLEANUP), - _active(ActionId.SUBMISSION_READ, PermissionId.SUBMISSION_READ_OWN, ActionOwner.TASK_CHECKER_CLEANUP), - _active(ActionId.SUBMISSION_CHECKER_RUN_LIST, PermissionId.SUBMISSION_READ_OWN, ActionOwner.TASK_CHECKER_CLEANUP), - _active(ActionId.CHECKER_RUN_READ, PermissionId.SUBMISSION_READ_OWN, ActionOwner.TASK_CHECKER_CLEANUP), - _active(ActionId.PROJECT_TASK_SUBMISSION_LIST, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.TASK_CHECKER_CLEANUP), - _active(ActionId.PROJECT_SUBMISSION_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.TASK_CHECKER_CLEANUP), - _active(ActionId.PROJECT_SUBMISSION_CHECKER_RUN_LIST, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.TASK_CHECKER_CLEANUP), - _active(ActionId.PROJECT_CHECKER_RUN_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.TASK_CHECKER_CLEANUP), + _active( + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12D, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, + PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, + ActionOwner.AUTH_12I, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, + PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionOwner.AUTH_12I, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionOwner.AUTH_12I, + ), + _active( + ActionId.PROJECT_REVIEW_POLICY_UPDATE, + PermissionId.PROJECT_REVIEW_POLICY_MANAGE, + ActionOwner.XINT_003_02B, + ), + _active( + ActionId.PROJECT_REVISION_POLICY_UPDATE, + PermissionId.PROJECT_REVIEW_POLICY_MANAGE, + ActionOwner.XINT_003_02B, + ), + _active( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + _active( + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + _active( + ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F2, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F3, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F2, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12F4, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_CORRECTION_REQUEST, + PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, + ActionOwner.AUTH_12F4, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F4, + ), + _active( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + _active( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + _active( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + _active( + ActionId.PROJECT_SETUP_RUN_UPDATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12B2 + ), + _active( + ActionId.PROJECT_GUIDE_ACTIVATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12H + ), + _active( + ActionId.TASK_SUBMISSION_LIST, + PermissionId.SUBMISSION_READ_OWN, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.SUBMISSION_READ, PermissionId.SUBMISSION_READ_OWN, ActionOwner.TASK_CHECKER_CLEANUP + ), + _active( + ActionId.SUBMISSION_CHECKER_RUN_LIST, + PermissionId.SUBMISSION_READ_OWN, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.CHECKER_RUN_READ, + PermissionId.SUBMISSION_READ_OWN, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.PROJECT_TASK_SUBMISSION_LIST, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.PROJECT_SUBMISSION_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.PROJECT_SUBMISSION_CHECKER_RUN_LIST, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.PROJECT_CHECKER_RUN_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.TASK_CHECKER_CLEANUP, + ), _active(ActionId.TASK_CLAIM, PermissionId.TASK_CLAIM, ActionOwner.TASK_PROJECT_GRANT), _active(ActionId.TASK_START, PermissionId.TASK_CLAIM, ActionOwner.TASK_PROJECT_GRANT), - _active(ActionId.TASK_WORK_CONTEXT_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.TASK_PROJECT_GRANT), + _active( + ActionId.TASK_WORK_CONTEXT_READ, + PermissionId.TASK_QUEUE_READ, + ActionOwner.TASK_PROJECT_GRANT, + ), _active(ActionId.TASK_GUIDE_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.PILOT_13), _active(ActionId.TASK_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.ARCH_03C5), - _active(ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.ARCH_03C5), + _active( + ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, + PermissionId.TASK_QUEUE_READ, + ActionOwner.ARCH_03C5, + ), _active(ActionId.PROJECT_TASK_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C5), - _active(ActionId.PROJECT_TASK_LOCKED_CONTEXT_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C6), - _active(ActionId.OPERATIONS_TASK_LOCKED_CONTEXT_READ, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.ARCH_03C6), - _active(ActionId.AUDIT_TASK_LOCKED_CONTEXT_READ, PermissionId.AUDIT_READ, ActionOwner.ARCH_03C6), + _active( + ActionId.PROJECT_TASK_LOCKED_CONTEXT_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.ARCH_03C6, + ), + _active( + ActionId.OPERATIONS_TASK_LOCKED_CONTEXT_READ, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.ARCH_03C6, + ), + _active( + ActionId.AUDIT_TASK_LOCKED_CONTEXT_READ, PermissionId.AUDIT_READ, ActionOwner.ARCH_03C6 + ), _active(ActionId.AUDIT_TASK_EVIDENCE_READ, PermissionId.AUDIT_READ, ActionOwner.ARCH_03C7), - _active(ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C5), + _active( + ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.ARCH_03C5, + ), _active(ActionId.TASK_QUEUE_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.ARCH_03C4), - _active(ActionId.PROJECT_TASK_QUEUE_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C4), - _active(ActionId.OPERATIONS_TASK_QUEUE_READ, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.ARCH_03C4), + _active( + ActionId.PROJECT_TASK_QUEUE_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C4 + ), + _active( + ActionId.OPERATIONS_TASK_QUEUE_READ, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.ARCH_03C4, + ), _active(ActionId.PROJECT_TASK_CREATE, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), _active(ActionId.PROJECT_TASK_IMPORT, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_02B), - _active(ActionId.PROJECT_TASK_IMPORT_BATCH_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_02B), - _active(ActionId.ARTIFACT_TASK_IMPORT_SOURCE_DECLARE, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_02A), - _active(ActionId.ARTIFACT_TASK_IMPORT_SOURCE_UPLOAD, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_02A), - _active(ActionId.ARTIFACT_TASK_IMPORT_SOURCE_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_02A), + _active( + ActionId.PROJECT_TASK_IMPORT_BATCH_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.PILOT_02B, + ), + _active( + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_DECLARE, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.PILOT_02A, + ), + _active( + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_UPLOAD, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.PILOT_02A, + ), + _active( + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.PILOT_02A, + ), _active(ActionId.PROJECT_TASK_SCREEN, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), _active(ActionId.PROJECT_TASK_RELEASE, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), - _active(ActionId.PROJECT_TASK_REVISION_REPAIR, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_08), - _active(ActionId.PROJECT_TASK_WORK_CONTEXT_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.TASK_PROJECT_GRANT), - _active(ActionId.OPERATIONS_TASK_START_OVERRIDE, PermissionId.OPERATIONS_TASK_START_OVERRIDE, ActionOwner.TASK_PROJECT_GRANT), - _planned(ActionId.OPERATIONS_SUBMISSION_GATE_REPAIR, PermissionId.OPERATIONS_SUBMISSION_GATE_REPAIR, ActionOwner.AUTH_14), - _active(ActionId.OPERATIONS_CHECKER_RETRY, PermissionId.OPERATIONS_CHECKER_RETRY, ActionOwner.AUTH_14), - _active(ActionId.OPERATIONS_CHECKER_RETRY_SOURCE_LIST, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.AUTH_14), - _active(ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_LIST, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.AUTH_14), - _active(ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_RECONCILE, PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.AUTH_14), - _active(ActionId.CHECKER_REGISTRY_READ, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.PILOT_04), - _active(ActionId.CHECKER_REGISTRY_REGISTER, PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.PILOT_04), + _active( + ActionId.PROJECT_TASK_REVISION_REPAIR, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.PILOT_08, + ), + _active( + ActionId.PROJECT_TASK_WORK_CONTEXT_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.TASK_PROJECT_GRANT, + ), + _active( + ActionId.OPERATIONS_TASK_START_OVERRIDE, + PermissionId.OPERATIONS_TASK_START_OVERRIDE, + ActionOwner.TASK_PROJECT_GRANT, + ), + _planned( + ActionId.OPERATIONS_SUBMISSION_GATE_REPAIR, + PermissionId.OPERATIONS_SUBMISSION_GATE_REPAIR, + ActionOwner.AUTH_14, + ), + _active( + ActionId.OPERATIONS_CHECKER_RETRY, + PermissionId.OPERATIONS_CHECKER_RETRY, + ActionOwner.AUTH_14, + ), + _active( + ActionId.OPERATIONS_CHECKER_RETRY_SOURCE_LIST, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.AUTH_14, + ), + _active( + ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_LIST, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.AUTH_14, + ), + _active( + ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_RECONCILE, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_14, + ), + _active( + ActionId.CHECKER_REGISTRY_READ, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.PILOT_04 + ), + _active( + ActionId.CHECKER_REGISTRY_REGISTER, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.PILOT_04, + ), _active(ActionId.SUBMISSION_CREATE, PermissionId.SUBMISSION_CREATE, ActionOwner.AUTH_14), _planned(ActionId.REVIEW_QUEUE_READ, PermissionId.REVIEW_QUEUE_READ, ActionOwner.AUTH_REV_05), - _planned(ActionId.REVIEW_QUEUE_INSPECT, PermissionId.REVIEW_QUEUE_INSPECT, ActionOwner.AUTH_REV_05), + _planned( + ActionId.REVIEW_QUEUE_INSPECT, PermissionId.REVIEW_QUEUE_INSPECT, ActionOwner.AUTH_REV_05 + ), _planned(ActionId.REVIEW_CLAIM, PermissionId.REVIEW_CLAIM, ActionOwner.AUTH_REV_06), _planned(ActionId.REVIEW_RELEASE, PermissionId.REVIEW_RELEASE, ActionOwner.AUTH_REV_06), - _planned(ActionId.REVIEW_DECLINE_PREFERENCE, PermissionId.REVIEW_DECLINE_PREFERENCE, ActionOwner.AUTH_REV_06), - _planned(ActionId.REVIEW_PREFERENCE_EXPIRY_RUN, PermissionId.OPERATIONS_TIMER_RUN, ActionOwner.AUTH_REV_06), - _planned(ActionId.REVIEW_LEASE_EXPIRY_RUN, PermissionId.OPERATIONS_TIMER_RUN, ActionOwner.AUTH_REV_06), - _planned(ActionId.REVIEW_CONTEXT_READ, PermissionId.SUBMISSION_READ_FOR_REVIEW, ActionOwner.AUTH_REV_07), + _planned( + ActionId.REVIEW_DECLINE_PREFERENCE, + PermissionId.REVIEW_DECLINE_PREFERENCE, + ActionOwner.AUTH_REV_06, + ), + _planned( + ActionId.REVIEW_PREFERENCE_EXPIRY_RUN, + PermissionId.OPERATIONS_TIMER_RUN, + ActionOwner.AUTH_REV_06, + ), + _planned( + ActionId.REVIEW_LEASE_EXPIRY_RUN, PermissionId.OPERATIONS_TIMER_RUN, ActionOwner.AUTH_REV_06 + ), + _planned( + ActionId.REVIEW_CONTEXT_READ, + PermissionId.SUBMISSION_READ_FOR_REVIEW, + ActionOwner.AUTH_REV_07, + ), _planned(ActionId.REVIEW_CHAIN_READ, PermissionId.REVIEW_CHAIN_READ, ActionOwner.AUTH_REV_07), - _planned(ActionId.REVIEW_FINDING_EVIDENCE_INGEST, PermissionId.REVIEW_DECISION, ActionOwner.AUTH_REV_07), + _planned( + ActionId.REVIEW_FINDING_EVIDENCE_INGEST, + PermissionId.REVIEW_DECISION, + ActionOwner.AUTH_REV_07, + ), _planned(ActionId.REVIEW_DECISION, PermissionId.REVIEW_DECISION, ActionOwner.AUTH_REV_08), - _planned(ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, PermissionId.SUBMISSION_CREATE, ActionOwner.AUTH_REV_09A), - _planned(ActionId.REVIEW_LEASE_FORCE_RELEASE, PermissionId.REVIEW_LEASE_FORCE_RELEASE, ActionOwner.AUTH_REV_11), - _planned(ActionId.REVIEW_QUEUE_ROUTING_OVERRIDE, PermissionId.REVIEW_QUEUE_OVERRIDE, ActionOwner.AUTH_REV_11), - _planned(ActionId.REVIEW_QUEUE_ROUTING_CORRECT, PermissionId.REVIEW_QUEUE_OVERRIDE, ActionOwner.AUTH_REV_11), - _planned(ActionId.REVIEW_QUEUE_CLOSE, PermissionId.REVIEW_QUEUE_OVERRIDE, ActionOwner.AUTH_REV_11), - _planned(ActionId.REVIEW_RECONCILE_RUN, PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.AUTH_REV_11), - _planned(ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE, PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.AUTH_REV_12), - _planned(ActionId.REVIEW_PROJECTION_REBUILD, PermissionId.OPERATIONS_PROJECTION_REBUILD, ActionOwner.AUTH_REV_12), - _planned(ActionId.REVIEW_REVISION_CONTEXT_REPAIR, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.XINT_003_08A), - _planned(ActionId.REVIEW_REVISION_CONTEXT_LEGACY_CLOSE, PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.XINT_003_08A), - _active(ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.XINT_003_08B), - _active(ActionId.ARTIFACT_BINDING_READ, PermissionId.ARTIFACT_BINDING_READ, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.ARTIFACT_REPLICA_READ, PermissionId.ARTIFACT_REPLICA_READ, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.ARTIFACT_RECEIPT_READ, PermissionId.ARTIFACT_RECEIPT_READ, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.ARTIFACT_VERIFICATION_JOB_READ, - PermissionId.ARTIFACT_VERIFICATION_JOB_READ, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.ARTIFACT_VERIFICATION_JOB_RETRY, - PermissionId.ARTIFACT_VERIFICATION_JOB_RETRY, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.ARTIFACT_RECOVERY_ATTEMPT_READ, - PermissionId.ARTIFACT_RECOVERY_ATTEMPT_READ, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.ARTIFACT_AUDIT_READ, PermissionId.ARTIFACT_AUDIT_READ, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.OPERATIONS_ARTIFACT_STORAGE_ADMISSION_READ, - PermissionId.OPERATIONS_STATUS_READ, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, PermissionId.ARTIFACT_GUIDE_SOURCE_INGEST, ActionOwner.XINT_002_04A), - _active(ActionId.ARTIFACT_GUIDE_SOURCE_READ, PermissionId.ARTIFACT_GUIDE_SOURCE_READ, ActionOwner.XINT_002_04B), - _active(ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, PermissionId.SUBMISSION_CREATE, ActionOwner.XINT_002_05A), - _active(ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.AUTH_ART_05), - _active(ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.AUTH_ART_06B), - _active(ActionId.ARTIFACT_VERIFICATION_EXECUTE, PermissionId.ARTIFACT_VERIFICATION_EXECUTE, ActionOwner.AUTH_ART_02D_INTERNAL), - _active(ActionId.ARTIFACT_PENDING_WORK_SCAN, PermissionId.ARTIFACT_PENDING_WORK_SCAN, ActionOwner.AUTH_ART_02D_INTERNAL), - _active(ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionOwner.AUTH_ART_02D_INTERNAL), - _active(ActionId.CHECKER_PRE_SUBMIT_EXECUTE, PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, ActionOwner.PILOT_04), - _active(ActionId.CHECKER_PRE_SUBMIT_FINALIZE, PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, ActionOwner.PILOT_04), - _active(ActionId.CHECKER_POST_SUBMIT_EXECUTE, PermissionId.CHECKER_POST_SUBMIT_EXECUTE, ActionOwner.ARCH_04D2), - _active(ActionId.CHECKER_POST_SUBMIT_FINALIZE, PermissionId.CHECKER_POST_SUBMIT_FINALIZE, ActionOwner.ARCH_04D2), - _active(ActionId.CHECKER_MODEL_CALL_PREPARE, PermissionId.CHECKER_MODEL_CALL_PREPARE, ActionOwner.PILOT_05), - _active(ActionId.CHECKER_MODEL_CALL_DISPATCH, PermissionId.CHECKER_MODEL_CALL_DISPATCH, ActionOwner.PILOT_05), - _active(ActionId.CHECKER_MODEL_CALL_RECOVER, PermissionId.CHECKER_MODEL_CALL_RECOVER, ActionOwner.PILOT_05), - _active(ActionId.CHECKER_MODEL_CALL_OBSERVE, PermissionId.CHECKER_MODEL_CALL_OBSERVE, ActionOwner.PILOT_05), - _active(ActionId.CHECKER_MODEL_CALL_COMPLETE, PermissionId.CHECKER_MODEL_CALL_COMPLETE, ActionOwner.PILOT_05), - _active(ActionId.CHECKER_MODEL_CALL_GRANT, PermissionId.CHECKER_MODEL_CALL_GRANT, ActionOwner.PILOT_05), - _active(ActionId.PROJECT_MODEL_PROFILE_PROVISION, PermissionId.PROJECT_MODEL_PROFILE_MANAGE, ActionOwner.PILOT_05), - _active(ActionId.PROJECT_MODEL_BUDGET_CONFIGURE, PermissionId.PROJECT_MODEL_BUDGET_MANAGE, ActionOwner.PILOT_05), - _active(ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, PermissionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, ActionOwner.PILOT_05), - _active(ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, ActionOwner.XINT_002_06A), - _active(ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, ActionOwner.AUTH_ART_06A), - _active(ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, ActionOwner.AUTH_ART_06B), - _planned(ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, ActionOwner.XINT_002_07), - _planned(ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE, PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.XINT_002_07), + _planned( + ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, + PermissionId.SUBMISSION_CREATE, + ActionOwner.AUTH_REV_09A, + ), + _planned( + ActionId.REVIEW_LEASE_FORCE_RELEASE, + PermissionId.REVIEW_LEASE_FORCE_RELEASE, + ActionOwner.AUTH_REV_11, + ), + _planned( + ActionId.REVIEW_QUEUE_ROUTING_OVERRIDE, + PermissionId.REVIEW_QUEUE_OVERRIDE, + ActionOwner.AUTH_REV_11, + ), + _planned( + ActionId.REVIEW_QUEUE_ROUTING_CORRECT, + PermissionId.REVIEW_QUEUE_OVERRIDE, + ActionOwner.AUTH_REV_11, + ), + _planned( + ActionId.REVIEW_QUEUE_CLOSE, PermissionId.REVIEW_QUEUE_OVERRIDE, ActionOwner.AUTH_REV_11 + ), + _planned( + ActionId.REVIEW_RECONCILE_RUN, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_REV_11, + ), + _planned( + ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_REV_12, + ), + _planned( + ActionId.REVIEW_PROJECTION_REBUILD, + PermissionId.OPERATIONS_PROJECTION_REBUILD, + ActionOwner.AUTH_REV_12, + ), + _planned( + ActionId.REVIEW_REVISION_CONTEXT_REPAIR, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.XINT_003_08A, + ), + _planned( + ActionId.REVIEW_REVISION_CONTEXT_LEGACY_CLOSE, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.XINT_003_08A, + ), + _active( + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.XINT_003_08B, + ), + _active( + ActionId.ARTIFACT_BINDING_READ, + PermissionId.ARTIFACT_BINDING_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_REPLICA_READ, + PermissionId.ARTIFACT_REPLICA_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_RECEIPT_READ, + PermissionId.ARTIFACT_RECEIPT_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_VERIFICATION_JOB_READ, + PermissionId.ARTIFACT_VERIFICATION_JOB_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_VERIFICATION_JOB_RETRY, + PermissionId.ARTIFACT_VERIFICATION_JOB_RETRY, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_RECOVERY_ATTEMPT_READ, + PermissionId.ARTIFACT_RECOVERY_ATTEMPT_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_AUDIT_READ, + PermissionId.ARTIFACT_AUDIT_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.OPERATIONS_ARTIFACT_STORAGE_ADMISSION_READ, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + PermissionId.ARTIFACT_GUIDE_SOURCE_INGEST, + ActionOwner.XINT_002_04A, + ), + _active( + ActionId.ARTIFACT_GUIDE_SOURCE_READ, + PermissionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionOwner.XINT_002_04B, + ), + _active( + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, + PermissionId.SUBMISSION_CREATE, + ActionOwner.XINT_002_05A, + ), + _active( + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.AUTH_ART_05, + ), + _active( + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.AUTH_ART_06B, + ), + _active( + ActionId.ARTIFACT_VERIFICATION_EXECUTE, + PermissionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + _active( + ActionId.ARTIFACT_PENDING_WORK_SCAN, + PermissionId.ARTIFACT_PENDING_WORK_SCAN, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + _active( + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + _active( + ActionId.CHECKER_PRE_SUBMIT_EXECUTE, + PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, + ActionOwner.PILOT_04, + ), + _active( + ActionId.CHECKER_PRE_SUBMIT_FINALIZE, + PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, + ActionOwner.PILOT_04, + ), + _active( + ActionId.CHECKER_POST_SUBMIT_EXECUTE, + PermissionId.CHECKER_POST_SUBMIT_EXECUTE, + ActionOwner.ARCH_04D2, + ), + _active( + ActionId.CHECKER_POST_SUBMIT_FINALIZE, + PermissionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionOwner.ARCH_04D2, + ), + _active( + ActionId.CHECKER_MODEL_CALL_PREPARE, + PermissionId.CHECKER_MODEL_CALL_PREPARE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_DISPATCH, + PermissionId.CHECKER_MODEL_CALL_DISPATCH, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_RECOVER, + PermissionId.CHECKER_MODEL_CALL_RECOVER, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_OBSERVE, + PermissionId.CHECKER_MODEL_CALL_OBSERVE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_COMPLETE, + PermissionId.CHECKER_MODEL_CALL_COMPLETE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_GRANT, + PermissionId.CHECKER_MODEL_CALL_GRANT, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_MODEL_PROFILE_PROVISION, + PermissionId.PROJECT_MODEL_PROFILE_MANAGE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_MODEL_BUDGET_CONFIGURE, + PermissionId.PROJECT_MODEL_BUDGET_MANAGE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, + PermissionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, + ActionOwner.XINT_002_06A, + ), + _active( + ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, + PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, + ActionOwner.AUTH_ART_06A, + ), + _active( + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + ActionOwner.AUTH_ART_06B, + ), + _planned( + ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, + PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, + ActionOwner.XINT_002_07, + ), + _planned( + ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE, + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.XINT_002_07, + ), *( _active(action, PermissionId.COMPENSATION_ADAPTER_BINDING_MANAGE, ActionOwner.ARCH_CP01A) for action in ( @@ -654,7 +1205,11 @@ def _active( _active(action, PermissionId.COMPENSATION_POLICY_MANAGE, ActionOwner.ARCH_CP01B) for action in _CONTRIBUTION_POLICY_ACTION_IDS ), - _active(ActionId.CONTRIBUTION_COMPENSATION_UNIT_CREATE, PermissionId.COMPENSATION_POLICY_MANAGE, ActionOwner.PROJECT_POINTS_UNIT_CREATION), + _active( + ActionId.CONTRIBUTION_COMPENSATION_UNIT_CREATE, + PermissionId.COMPENSATION_POLICY_MANAGE, + ActionOwner.PROJECT_POINTS_UNIT_CREATION, + ), ) PERMISSION_IDS = frozenset(PermissionId) @@ -675,6 +1230,10 @@ def _active( PermissionId.CHECKER_MODEL_CALL_OBSERVE, PermissionId.CHECKER_MODEL_CALL_COMPLETE, PermissionId.CHECKER_MODEL_CALL_GRANT, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, PermissionId.PROJECT_MODEL_PROFILE_MANAGE, PermissionId.PROJECT_MODEL_BUDGET_MANAGE, PermissionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, @@ -713,28 +1272,37 @@ def _active( ) HISTORICAL_PERMISSION_IDS = PERMISSION_IDS - NEW_PERMISSION_IDS + def _require_catalogue_counts() -> None: """Keep the closed action inventory and permission boundary exact.""" - if len(PERMISSION_IDS) != 91 or len(ACTION_IDS) != 186: + if len(PERMISSION_IDS) != 95 or len(ACTION_IDS) != 190: raise RuntimeError("authorization catalogue count mismatch") - if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 42: + if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 46: raise RuntimeError("authorization permission boundary mismatch") -POST_POLICY_MUTATION_ACTION_IDS = frozenset({ - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, -}) -POST_POLICY_ACTION_IDS = POST_POLICY_MUTATION_ACTION_IDS | {ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ} -POST_POLICY_HUMAN_ACTION_IDS = POST_POLICY_ACTION_IDS - {ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE} +POST_POLICY_MUTATION_ACTION_IDS = frozenset( + { + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, + } +) +POST_POLICY_ACTION_IDS = POST_POLICY_MUTATION_ACTION_IDS | { + ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ +} +POST_POLICY_HUMAN_ACTION_IDS = POST_POLICY_ACTION_IDS - { + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE +} -GUIDE_PROPOSAL_ACTION_IDS = frozenset({ - ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ, - ActionId.PROJECT_GUIDE_COMPILATION_CORRECTION_REQUEST, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, -}) +GUIDE_PROPOSAL_ACTION_IDS = frozenset( + { + ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ, + ActionId.PROJECT_GUIDE_COMPILATION_CORRECTION_REQUEST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, + } +) def _validate_action_definition_rows(definitions: tuple[ActionDefinition, ...]) -> None: @@ -764,106 +1332,117 @@ def _validate_action_definitions(definitions: tuple[ActionDefinition, ...]) -> N raise RuntimeError("authorization action owner catalogue is incomplete") -_ACTIVE_ACTION_BOUNDARY = frozenset({ - ActionId.TASK_POST_SUBMIT_ROUTE, - ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, - ActionId.TASK_REVISION_EXPIRE, - ActionId.TASK_REVISION_RECOVER, - ActionId.OUTBOX_DISPATCH, - ActionId.PROJECT_GUIDE_ACTIVATE, - *_CONTRIBUTION_POLICY_ACTION_IDS, - ActionId.CONTRIBUTION_COMPENSATION_UNIT_CREATE, - ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, - *GUIDE_PROPOSAL_ACTION_IDS, - *POST_POLICY_MUTATION_ACTION_IDS, - ActionId.ACTOR_PROFILE_READ_SELF, - ActionId.ACTOR_PROFILE_UPDATE_SELF, - ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, - ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ, - ActionId.ADMIN_ROLE_GRANT_LIST, - ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ, - ActionId.ADMIN_ROLE_GRANT_ISSUE, - ActionId.ADMIN_ROLE_GRANT_REVOKE, - ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, - ActionId.ACTOR_PROFILE_READ, - ActionId.ACTOR_IDENTITY_LINK_READ, - ActionId.ACTOR_SERVICE_PROVISION, - ActionId.ACTOR_PROFILE_SUSPEND, - ActionId.ACTOR_PROFILE_REACTIVATE, - ActionId.ACTOR_PROFILE_DEACTIVATE, - ActionId.ACTOR_IDENTITY_LINK_REVOKE, - ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, - ActionId.CHECKER_REGISTRY_READ, - ActionId.CHECKER_REGISTRY_REGISTER, - ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, - ActionId.PROJECT_ROLE_GRANT_LIST, - ActionId.PROJECT_ROLE_GRANT_READ, - ActionId.PROJECT_ROLE_GRANT_ISSUE, - ActionId.PROJECT_ROLE_GRANT_REVOKE, - ActionId.PROJECT_CREATE, ActionId.PROJECT_GUIDE_CREATE, - ActionId.PROJECT_GUIDE_UPDATE, - ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, - ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, - ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, - ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, - ActionId.PROJECT_SETUP_RUN_UPDATE, - ActionId.PROJECT_REVIEW_POLICY_UPDATE, - ActionId.PROJECT_REVISION_POLICY_UPDATE, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, - ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, - ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, - ActionId.PROJECT_READ, - ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, - ActionId.PROJECT_SETUP_RUN_READ, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, - ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, - ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, - ActionId.PROJECT_ACTIVE_GUIDE_READ, - ActionId.ARTIFACT_BINDING_READ, - ActionId.ARTIFACT_REPLICA_READ, - ActionId.ARTIFACT_RECEIPT_READ, - ActionId.ARTIFACT_VERIFICATION_JOB_READ, - ActionId.ARTIFACT_VERIFICATION_JOB_RETRY, - ActionId.ARTIFACT_RECOVERY_ATTEMPT_READ, - ActionId.ARTIFACT_AUDIT_READ, - ActionId.OPERATIONS_ARTIFACT_STORAGE_ADMISSION_READ, - ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, - ActionId.ARTIFACT_GUIDE_SOURCE_READ, - ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, - ActionId.SUBMISSION_CREATE, - ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, - ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, - ActionId.ARTIFACT_VERIFICATION_EXECUTE, - ActionId.ARTIFACT_PENDING_WORK_SCAN, - ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, - ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, - ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, - ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, - ActionId.CHECKER_POST_SUBMIT_EXECUTE, ActionId.CHECKER_POST_SUBMIT_FINALIZE, - ActionId.CHECKER_MODEL_CALL_PREPARE, - ActionId.CHECKER_MODEL_CALL_DISPATCH, - ActionId.CHECKER_MODEL_CALL_RECOVER, - ActionId.CHECKER_MODEL_CALL_OBSERVE, - ActionId.CHECKER_MODEL_CALL_COMPLETE, - ActionId.CHECKER_MODEL_CALL_GRANT, - ActionId.PROJECT_MODEL_PROFILE_PROVISION, - ActionId.PROJECT_MODEL_BUDGET_CONFIGURE, - ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, - ActionId.CHECKER_PRE_SUBMIT_EXECUTE, ActionId.CHECKER_PRE_SUBMIT_FINALIZE, - ActionId.COMPENSATION_ADAPTER_BINDING_READ, - ActionId.COMPENSATION_ADAPTER_BINDING_CREATE, - ActionId.COMPENSATION_ADAPTER_BINDING_SUSPEND, - ActionId.COMPENSATION_ADAPTER_BINDING_RESUME, -}) +_ACTIVE_ACTION_BOUNDARY = frozenset( + { + ActionId.TASK_POST_SUBMIT_ROUTE, + ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, + ActionId.TASK_REVISION_EXPIRE, + ActionId.TASK_REVISION_RECOVER, + ActionId.OUTBOX_DISPATCH, + ActionId.PROJECT_GUIDE_ACTIVATE, + *_CONTRIBUTION_POLICY_ACTION_IDS, + ActionId.CONTRIBUTION_COMPENSATION_UNIT_CREATE, + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, + *GUIDE_PROPOSAL_ACTION_IDS, + *POST_POLICY_MUTATION_ACTION_IDS, + ActionId.ACTOR_PROFILE_READ_SELF, + ActionId.ACTOR_PROFILE_UPDATE_SELF, + ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, + ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ, + ActionId.ADMIN_ROLE_GRANT_LIST, + ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ, + ActionId.ADMIN_ROLE_GRANT_ISSUE, + ActionId.ADMIN_ROLE_GRANT_REVOKE, + ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, + ActionId.ACTOR_PROFILE_READ, + ActionId.ACTOR_IDENTITY_LINK_READ, + ActionId.ACTOR_SERVICE_PROVISION, + ActionId.ACTOR_PROFILE_SUSPEND, + ActionId.ACTOR_PROFILE_REACTIVATE, + ActionId.ACTOR_PROFILE_DEACTIVATE, + ActionId.ACTOR_IDENTITY_LINK_REVOKE, + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + ActionId.CHECKER_REGISTRY_READ, + ActionId.CHECKER_REGISTRY_REGISTER, + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_ROLE_GRANT_LIST, + ActionId.PROJECT_ROLE_GRANT_READ, + ActionId.PROJECT_ROLE_GRANT_ISSUE, + ActionId.PROJECT_ROLE_GRANT_REVOKE, + ActionId.PROJECT_CREATE, + ActionId.PROJECT_GUIDE_CREATE, + ActionId.PROJECT_GUIDE_UPDATE, + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionId.PROJECT_SETUP_RUN_UPDATE, + ActionId.PROJECT_REVIEW_POLICY_UPDATE, + ActionId.PROJECT_REVISION_POLICY_UPDATE, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, + ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, + ActionId.PROJECT_READ, + ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, + ActionId.PROJECT_SETUP_RUN_READ, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, + ActionId.PROJECT_ACTIVE_GUIDE_READ, + ActionId.ARTIFACT_BINDING_READ, + ActionId.ARTIFACT_REPLICA_READ, + ActionId.ARTIFACT_RECEIPT_READ, + ActionId.ARTIFACT_VERIFICATION_JOB_READ, + ActionId.ARTIFACT_VERIFICATION_JOB_RETRY, + ActionId.ARTIFACT_RECOVERY_ATTEMPT_READ, + ActionId.ARTIFACT_AUDIT_READ, + ActionId.OPERATIONS_ARTIFACT_STORAGE_ADMISSION_READ, + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + ActionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, + ActionId.SUBMISSION_CREATE, + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, + ActionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionId.ARTIFACT_PENDING_WORK_SCAN, + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + ActionId.CHECKER_POST_SUBMIT_EXECUTE, + ActionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionId.CHECKER_MODEL_CALL_PREPARE, + ActionId.CHECKER_MODEL_CALL_DISPATCH, + ActionId.CHECKER_MODEL_CALL_RECOVER, + ActionId.CHECKER_MODEL_CALL_OBSERVE, + ActionId.CHECKER_MODEL_CALL_COMPLETE, + ActionId.CHECKER_MODEL_CALL_GRANT, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + ActionId.PROJECT_MODEL_PROFILE_PROVISION, + ActionId.PROJECT_MODEL_BUDGET_CONFIGURE, + ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, + ActionId.CHECKER_PRE_SUBMIT_EXECUTE, + ActionId.CHECKER_PRE_SUBMIT_FINALIZE, + ActionId.COMPENSATION_ADAPTER_BINDING_READ, + ActionId.COMPENSATION_ADAPTER_BINDING_CREATE, + ActionId.COMPENSATION_ADAPTER_BINDING_SUSPEND, + ActionId.COMPENSATION_ADAPTER_BINDING_RESUME, + } +) -def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyType[ActionId, ActionDefinition]: +def _index_actions( + definitions: tuple[ActionDefinition, ...], +) -> MappingProxyType[ActionId, ActionDefinition]: _validate_action_definition_rows(definitions) indexed = {definition.action_id: definition for definition in definitions} _require_catalogue_counts() @@ -874,17 +1453,30 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp for definition in definitions if definition.availability is ActionAvailability.ACTIVE } != _ACTIVE_ACTION_BOUNDARY | CONTRIBUTION_READ_ACTIONS | CONTRIBUTOR_READ_ACTIONS | { - ActionId.TASK_CLAIM, ActionId.TASK_START, ActionId.TASK_WORK_CONTEXT_READ, ActionId.TASK_GUIDE_READ, - ActionId.PROJECT_TASK_WORK_CONTEXT_READ, ActionId.OPERATIONS_TASK_START_OVERRIDE, - ActionId.PROJECT_TASK_CREATE, ActionId.PROJECT_TASK_SCREEN, ActionId.PROJECT_TASK_RELEASE, + ActionId.TASK_CLAIM, + ActionId.TASK_START, + ActionId.TASK_WORK_CONTEXT_READ, + ActionId.TASK_GUIDE_READ, + ActionId.PROJECT_TASK_WORK_CONTEXT_READ, + ActionId.OPERATIONS_TASK_START_OVERRIDE, + ActionId.PROJECT_TASK_CREATE, + ActionId.PROJECT_TASK_SCREEN, + ActionId.PROJECT_TASK_RELEASE, ActionId.PROJECT_TASK_REVISION_REPAIR, - ActionId.PROJECT_TASK_IMPORT, ActionId.PROJECT_TASK_IMPORT_BATCH_READ, - ActionId.ARTIFACT_TASK_IMPORT_SOURCE_DECLARE, ActionId.ARTIFACT_TASK_IMPORT_SOURCE_UPLOAD, + ActionId.PROJECT_TASK_IMPORT, + ActionId.PROJECT_TASK_IMPORT_BATCH_READ, + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_DECLARE, + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_UPLOAD, ActionId.ARTIFACT_TASK_IMPORT_SOURCE_READ, - ActionId.TASK_QUEUE_READ, ActionId.PROJECT_TASK_QUEUE_READ, ActionId.OPERATIONS_TASK_QUEUE_READ, - ActionId.TASK_READ, ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, - ActionId.PROJECT_TASK_READ, ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, - ActionId.AUDIT_TASK_EVIDENCE_READ, ActionId.OPERATIONS_CHECKER_RETRY, + ActionId.TASK_QUEUE_READ, + ActionId.PROJECT_TASK_QUEUE_READ, + ActionId.OPERATIONS_TASK_QUEUE_READ, + ActionId.TASK_READ, + ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, + ActionId.PROJECT_TASK_READ, + ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, + ActionId.AUDIT_TASK_EVIDENCE_READ, + ActionId.OPERATIONS_CHECKER_RETRY, ActionId.OPERATIONS_CHECKER_RETRY_SOURCE_LIST, ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_LIST, ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_RECONCILE, @@ -899,7 +1491,9 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp _SERVICE_ACTIONS = { ServiceIdentity.TASK_POST_SUBMIT_ROUTER: frozenset({ActionId.TASK_POST_SUBMIT_ROUTE}), - ServiceIdentity.CHECKER_PRE_SUBMIT: frozenset({ActionId.CHECKER_PRE_SUBMIT_EXECUTE, ActionId.CHECKER_PRE_SUBMIT_FINALIZE}), + ServiceIdentity.CHECKER_PRE_SUBMIT: frozenset( + {ActionId.CHECKER_PRE_SUBMIT_EXECUTE, ActionId.CHECKER_PRE_SUBMIT_FINALIZE} + ), ServiceIdentity.CHECKER_POST_SUBMIT: frozenset( { ActionId.CHECKER_POST_SUBMIT_EXECUTE, @@ -912,10 +1506,15 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp ActionId.CHECKER_MODEL_CALL_GRANT, } ), - ServiceIdentity.TASK_ASSIGNMENT_RECONCILER: frozenset({ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE}), - ServiceIdentity.TASK_REVISION_EXPIRER: frozenset({ - ActionId.TASK_REVISION_EXPIRE, ActionId.TASK_REVISION_RECOVER, - }), + ServiceIdentity.TASK_ASSIGNMENT_RECONCILER: frozenset( + {ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE} + ), + ServiceIdentity.TASK_REVISION_EXPIRER: frozenset( + { + ActionId.TASK_REVISION_EXPIRE, + ActionId.TASK_REVISION_RECOVER, + } + ), ServiceIdentity.OUTBOX_DISPATCHER: frozenset({ActionId.OUTBOX_DISPATCH}), ServiceIdentity.ARTIFACT_VERIFIER: frozenset({ActionId.ARTIFACT_VERIFICATION_EXECUTE}), ServiceIdentity.ARTIFACT_PUT_RESOLVER: frozenset({ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE}), @@ -944,6 +1543,10 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, ActionId.PROJECT_SETUP_RUN_UPDATE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, } ), ServiceIdentity.REVIEW_PREFERENCE_EXPIRY: frozenset({ActionId.REVIEW_PREFERENCE_EXPIRY_RUN}), @@ -958,9 +1561,7 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp ServiceIdentity.REVIEW_PROJECTION: frozenset({ActionId.REVIEW_PROJECTION_REBUILD}), } -ACTION_BEARING_SERVICE_IDENTITIES = SERVICE_IDENTITIES - { - ServiceIdentity.COMPENSATION_ADAPTER -} +ACTION_BEARING_SERVICE_IDENTITIES = SERVICE_IDENTITIES - {ServiceIdentity.COMPENSATION_ADAPTER} _EXPECTED_SERVICE_ACTION_MEMBERSHIPS = frozenset( (identity, action) @@ -1003,6 +1604,10 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE), (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE), (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_SETUP_RUN_UPDATE), + (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE), + (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH), + (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE), + (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE), (ServiceIdentity.REVIEW_PREFERENCE_EXPIRY, ActionId.REVIEW_PREFERENCE_EXPIRY_RUN), (ServiceIdentity.REVIEW_LEASE_EXPIRY, ActionId.REVIEW_LEASE_EXPIRY_RUN), ( @@ -1031,6 +1636,10 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp ActionId.CHECKER_MODEL_CALL_OBSERVE, ActionId.CHECKER_MODEL_CALL_COMPLETE, ActionId.CHECKER_MODEL_CALL_GRANT, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, ActionId.TASK_REVISION_EXPIRE, @@ -1057,11 +1666,26 @@ def _index_service_actions( rows: dict[ServiceIdentity, frozenset[ActionId]], ) -> MappingProxyType[ServiceIdentity, frozenset[ActionId]]: expected_metadata = { - ActionId.TASK_POST_SUBMIT_ROUTE: (PermissionId.TASK_POST_SUBMIT_ROUTE, ActionOwner.ARCH_04E2), - ActionId.CHECKER_PRE_SUBMIT_EXECUTE: (PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, ActionOwner.PILOT_04), - ActionId.CHECKER_PRE_SUBMIT_FINALIZE: (PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, ActionOwner.PILOT_04), - ActionId.CHECKER_POST_SUBMIT_EXECUTE: (PermissionId.CHECKER_POST_SUBMIT_EXECUTE, ActionOwner.ARCH_04D2), - ActionId.CHECKER_POST_SUBMIT_FINALIZE: (PermissionId.CHECKER_POST_SUBMIT_FINALIZE, ActionOwner.ARCH_04D2), + ActionId.TASK_POST_SUBMIT_ROUTE: ( + PermissionId.TASK_POST_SUBMIT_ROUTE, + ActionOwner.ARCH_04E2, + ), + ActionId.CHECKER_PRE_SUBMIT_EXECUTE: ( + PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, + ActionOwner.PILOT_04, + ), + ActionId.CHECKER_PRE_SUBMIT_FINALIZE: ( + PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, + ActionOwner.PILOT_04, + ), + ActionId.CHECKER_POST_SUBMIT_EXECUTE: ( + PermissionId.CHECKER_POST_SUBMIT_EXECUTE, + ActionOwner.ARCH_04D2, + ), + ActionId.CHECKER_POST_SUBMIT_FINALIZE: ( + PermissionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionOwner.ARCH_04D2, + ), ActionId.CHECKER_MODEL_CALL_PREPARE: ( PermissionId.CHECKER_MODEL_CALL_PREPARE, ActionOwner.PILOT_05, @@ -1086,38 +1710,126 @@ def _index_service_actions( PermissionId.CHECKER_MODEL_CALL_GRANT, ActionOwner.PILOT_05, ), - ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE: (PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, ActionOwner.ARCH_03C1), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionOwner.PILOT_05, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionOwner.PILOT_05, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionOwner.PILOT_05, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + ActionOwner.PILOT_05, + ), + ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE: ( + PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, + ActionOwner.ARCH_03C1, + ), ActionId.TASK_REVISION_EXPIRE: (PermissionId.TASK_REVISION_EXPIRE, ActionOwner.PILOT_08), ActionId.TASK_REVISION_RECOVER: (PermissionId.TASK_REVISION_RECOVER, ActionOwner.PILOT_08), ActionId.OUTBOX_DISPATCH: (PermissionId.OUTBOX_DISPATCH, ActionOwner.AUTH_OUTBOX_01), - ActionId.ARTIFACT_VERIFICATION_EXECUTE: (PermissionId.ARTIFACT_VERIFICATION_EXECUTE, ActionOwner.AUTH_ART_02D_INTERNAL), - ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: (PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionOwner.AUTH_ART_02D_INTERNAL), - ActionId.ARTIFACT_PENDING_WORK_SCAN: (PermissionId.ARTIFACT_PENDING_WORK_SCAN, ActionOwner.AUTH_ART_02D_INTERNAL), - ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE: (PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.AUTH_ART_05), - ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE: (PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.AUTH_ART_06B), - ActionId.ARTIFACT_GUIDE_SOURCE_READ: (PermissionId.ARTIFACT_GUIDE_SOURCE_READ, ActionOwner.XINT_002_04B), - ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: (PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, ActionOwner.XINT_002_06A), - ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE: (PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, ActionOwner.AUTH_ART_06A), - ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE: (PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, ActionOwner.AUTH_ART_06B), - ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE: (PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, ActionOwner.XINT_002_07), - ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE: (PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.XINT_002_07), - ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: (PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12E), - ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC: (PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionOwner.AUTH_12I), - ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE: (PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionOwner.AUTH_12I), - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: (PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12F3), - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: (PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12G), - ActionId.PROJECT_SETUP_RUN_UPDATE: (PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12B2), - ActionId.REVIEW_PREFERENCE_EXPIRY_RUN: (PermissionId.OPERATIONS_TIMER_RUN, ActionOwner.AUTH_REV_06), - ActionId.REVIEW_LEASE_EXPIRY_RUN: (PermissionId.OPERATIONS_TIMER_RUN, ActionOwner.AUTH_REV_06), - ActionId.REVIEW_RECONCILE_RUN: (PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.AUTH_REV_11), - ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE: (PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.AUTH_REV_12), - ActionId.REVIEW_PROJECTION_REBUILD: (PermissionId.OPERATIONS_PROJECTION_REBUILD, ActionOwner.AUTH_REV_12), + ActionId.ARTIFACT_VERIFICATION_EXECUTE: ( + PermissionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ( + PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + ActionId.ARTIFACT_PENDING_WORK_SCAN: ( + PermissionId.ARTIFACT_PENDING_WORK_SCAN, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE: ( + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.AUTH_ART_05, + ), + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE: ( + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.AUTH_ART_06B, + ), + ActionId.ARTIFACT_GUIDE_SOURCE_READ: ( + PermissionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionOwner.XINT_002_04B, + ), + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( + PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, + ActionOwner.XINT_002_06A, + ), + ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( + PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, + ActionOwner.AUTH_ART_06A, + ), + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE: ( + PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + ActionOwner.AUTH_ART_06B, + ), + ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE: ( + PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, + ActionOwner.XINT_002_07, + ), + ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE: ( + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.XINT_002_07, + ), + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: ( + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC: ( + PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionOwner.AUTH_12I, + ), + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionOwner.AUTH_12I, + ), + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: ( + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F3, + ), + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: ( + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + ActionId.PROJECT_SETUP_RUN_UPDATE: ( + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12B2, + ), + ActionId.REVIEW_PREFERENCE_EXPIRY_RUN: ( + PermissionId.OPERATIONS_TIMER_RUN, + ActionOwner.AUTH_REV_06, + ), + ActionId.REVIEW_LEASE_EXPIRY_RUN: ( + PermissionId.OPERATIONS_TIMER_RUN, + ActionOwner.AUTH_REV_06, + ), + ActionId.REVIEW_RECONCILE_RUN: ( + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_REV_11, + ), + ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE: ( + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_REV_12, + ), + ActionId.REVIEW_PROJECTION_REBUILD: ( + PermissionId.OPERATIONS_PROJECTION_REBUILD, + ActionOwner.AUTH_REV_12, + ), } if set(rows) != ACTION_BEARING_SERVICE_IDENTITIES: raise RuntimeError("service action matrix identity mismatch") if any(not actions for actions in rows.values()): raise RuntimeError("service action matrix row mismatch") - if frozenset((i, a) for i, actions in rows.items() for a in actions) != _EXPECTED_SERVICE_ACTION_MEMBERSHIPS: + if ( + frozenset((i, a) for i, actions in rows.items() for a in actions) + != _EXPECTED_SERVICE_ACTION_MEMBERSHIPS + ): raise RuntimeError("service action matrix row mismatch") if not FUTURE_INTENT_REQUIRED_ACTIONS.isdisjoint( action for actions in rows.values() for action in actions diff --git a/backend/app/modules/authorization/domain/action_groups.py b/backend/app/modules/authorization/domain/action_groups.py index 72e6b28ab..6877c02c9 100644 --- a/backend/app/modules/authorization/domain/action_groups.py +++ b/backend/app/modules/authorization/domain/action_groups.py @@ -5,7 +5,7 @@ from app.modules.authorization.catalogue import ActionId from app.modules.authorization.domain import ( adapter_bindings, - checker_models, + model_usage as checker_models, checker_outputs, checker_registry, contribution_policies, diff --git a/backend/app/modules/authorization/domain/audit_targets.py b/backend/app/modules/authorization/domain/audit_targets.py index 8888dba56..624239a67 100644 --- a/backend/app/modules/authorization/domain/audit_targets.py +++ b/backend/app/modules/authorization/domain/audit_targets.py @@ -67,7 +67,7 @@ def _compensation_unit_target(resource): def _model_administration_target(resource): """Audit project model administration against its exact owning project.""" - from app.modules.authorization.domain.checker_models import ( + from app.modules.authorization.domain.model_usage import ( ProjectModelAdministrationResourceContext, ) diff --git a/backend/app/modules/authorization/domain/checker_models.py b/backend/app/modules/authorization/domain/model_usage.py similarity index 70% rename from backend/app/modules/authorization/domain/checker_models.py rename to backend/app/modules/authorization/domain/model_usage.py index b3640ca4d..b878bbf66 100644 --- a/backend/app/modules/authorization/domain/checker_models.py +++ b/backend/app/modules/authorization/domain/model_usage.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from app.modules.authorization.catalogue import ActionId -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelBudgetProvisioningFacts, ModelCallEffectAuthorityFacts, ModelCallEvidence, @@ -17,6 +17,9 @@ ModelPaidSlotKey, ModelProfileProvisioningFacts, PreparedModelCallAttempt, + PreparedSetupModelUsageEffect, + SetupModelEffectAuthorityFacts, + SetupModelUsageSlotKey, model_action_digest, ) @@ -30,10 +33,17 @@ OBSERVE = ActionId.CHECKER_MODEL_CALL_OBSERVE COMPLETE = ActionId.CHECKER_MODEL_CALL_COMPLETE GRANT = ActionId.CHECKER_MODEL_CALL_GRANT +SETUP_PREPARE = ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE +SETUP_DISPATCH = ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH +SETUP_OBSERVE = ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE +SETUP_COMPLETE = ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE MODEL_ADMIN_ACTIONS = frozenset({PROFILE_PROVISION, BUDGET_CONFIGURE, RECOVERY_CHILD_AUTHORIZE}) CHECKER_MODEL_CALL_ACTIONS = frozenset({PREPARE, DISPATCH, RECOVER, OBSERVE, COMPLETE, GRANT}) -CHECKER_MODEL_ACTIONS = MODEL_ADMIN_ACTIONS | CHECKER_MODEL_CALL_ACTIONS +SETUP_MODEL_USAGE_ACTIONS = frozenset( + {SETUP_PREPARE, SETUP_DISPATCH, SETUP_OBSERVE, SETUP_COMPLETE} +) +CHECKER_MODEL_ACTIONS = MODEL_ADMIN_ACTIONS | CHECKER_MODEL_CALL_ACTIONS | SETUP_MODEL_USAGE_ACTIONS _STRICT_FROZEN = ConfigDict(extra="forbid", frozen=True, strict=True, revalidate_instances="always") _Sha256 = str @@ -98,6 +108,45 @@ class ModelCallCompleteAuthorityFacts(BaseModel): terminal_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") +class SetupModelPrepareAuthorityFacts(BaseModel): + """Exact setup call slot and canonical first request commitment.""" + + model_config = _STRICT_FROZEN + slot: SetupModelUsageSlotKey + request_sha256: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class SetupModelDispatchAuthorityFacts(BaseModel): + """Exact prepared setup effect and verified request evidence.""" + + model_config = _STRICT_FROZEN + prepared: PreparedSetupModelUsageEffect + request_evidence: ModelCallEvidence + + +class SetupModelObserveAuthorityFacts(BaseModel): + """Lease-independent uncertain setup-effect observation.""" + + model_config = _STRICT_FROZEN + effect: SetupModelEffectAuthorityFacts + provider_response_id: str | None = Field( + default=None, + min_length=1, + max_length=256, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", + ) + observation_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class SetupModelCompleteAuthorityFacts(BaseModel): + """Lease-independent terminal setup effect and optional response evidence.""" + + model_config = _STRICT_FROZEN + effect: SetupModelEffectAuthorityFacts + response_evidence: ModelCallEvidence | None + terminal_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + class ModelCallRecoveryChildAuthorityFacts(BaseModel): """Operator-authorized child after one exact terminal parent effect.""" @@ -115,6 +164,12 @@ class ModelCallRecoveryChildAuthorityFacts(BaseModel): | ModelCallCompleteAuthorityFacts | ModelCallGrantAuthorityFacts ) +SetupRuntimeFacts = ( + SetupModelPrepareAuthorityFacts + | SetupModelDispatchAuthorityFacts + | SetupModelObserveAuthorityFacts + | SetupModelCompleteAuthorityFacts +) AdminFacts = ( ModelProfileProvisioningFacts | ModelBudgetProvisioningFacts @@ -189,8 +244,40 @@ def exact_operation(self) -> Self: return self +class SetupModelUsageResourceContext(BaseModel): + """One exact PROJECT_SETUP model-use phase in a project scope.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["setup_model_usage"] = "setup_model_usage" + resource_id: UUID + scope_project_id: UUID + action_id: ActionId + facts: SetupRuntimeFacts + facts_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + def exact_phase(self) -> Self: + expected = { + SETUP_PREPARE: SetupModelPrepareAuthorityFacts, + SETUP_DISPATCH: SetupModelDispatchAuthorityFacts, + SETUP_OBSERVE: SetupModelObserveAuthorityFacts, + SETUP_COMPLETE: SetupModelCompleteAuthorityFacts, + }.get(self.action_id) + if expected is None or type(self.facts) is not expected: + raise ValueError("invalid setup model usage authority phase") + project_id, resource_id = _setup_runtime_identity(self.facts) + if ( + self.scope_project_id != project_id + or self.resource_id != resource_id + or self.facts_digest != model_action_digest(self.action_id.value, self.facts) + ): + raise ValueError("invalid setup model usage authority resource") + return self + + CHECKER_MODEL_RESOURCE_BY_ACTION = { **{action: CheckerModelCallResourceContext for action in CHECKER_MODEL_CALL_ACTIONS}, + **{action: SetupModelUsageResourceContext for action in SETUP_MODEL_USAGE_ACTIONS}, **{action: ProjectModelAdministrationResourceContext for action in MODEL_ADMIN_ACTIONS}, } MODEL_ADMIN_RESOURCE_BY_ACTION = { @@ -230,9 +317,28 @@ def project_model_admin_resource( ) +def setup_model_usage_resource( + action: ActionId, facts: SetupRuntimeFacts +) -> SetupModelUsageResourceContext: + """Build the only valid PROJECT_SETUP resource for one model-use phase.""" + project_id, resource_id = _setup_runtime_identity(facts) + return SetupModelUsageResourceContext( + resource_id=resource_id, + scope_project_id=project_id, + action_id=action, + facts=facts, + facts_digest=model_action_digest(action.value, facts), + ) + + def parse_checker_model_prepare( action: ActionId, value: object, invalid_error: type[Exception] -) -> CheckerModelCallResourceContext | ProjectModelAdministrationResourceContext | None: +) -> ( + CheckerModelCallResourceContext + | SetupModelUsageResourceContext + | ProjectModelAdministrationResourceContext + | None +): """Parse the exact resource committed before AUTH locks product owners.""" expected = CHECKER_MODEL_RESOURCE_BY_ACTION.get(action) if expected is None: @@ -250,7 +356,12 @@ def parse_checker_model_prepare( def checker_model_prepare_matches( - prepared: CheckerModelCallResourceContext | ProjectModelAdministrationResourceContext | None, + prepared: ( + CheckerModelCallResourceContext + | SetupModelUsageResourceContext + | ProjectModelAdministrationResourceContext + | None + ), resource: object, ) -> bool: """Require final facts to equal the resource committed at preparation.""" @@ -299,6 +410,18 @@ def _execution_matches_slot(execution: ModelCallExecutionFacts, slot: ModelPaidS ) +def _setup_runtime_identity(facts: SetupRuntimeFacts) -> tuple[UUID, UUID]: + if type(facts) is SetupModelPrepareAuthorityFacts: + return facts.slot.project_id, facts.slot.compilation_attempt_id + if type(facts) is SetupModelDispatchAuthorityFacts: + return facts.prepared.slot_key.project_id, facts.prepared.effect_id + if type(facts) in {SetupModelObserveAuthorityFacts, SetupModelCompleteAuthorityFacts}: + if facts.effect.project_id != facts.effect.slot_key.project_id: + raise ValueError("setup model project mismatch") + return facts.effect.project_id, facts.effect.effect_id + raise ValueError("invalid setup model runtime facts") + + def _admin_identity(facts: AdminFacts, actor_profile_id: UUID) -> tuple[UUID, UUID, UUID]: if type(facts) in {ModelProfileProvisioningFacts, ModelBudgetProvisioningFacts}: return facts.spec.project_id, facts.operation_id, facts.actor_profile_id @@ -331,8 +454,19 @@ def _admin_identity(facts: AdminFacts, actor_profile_id: UUID) -> tuple[UUID, UU "ProjectModelAdministrationResourceContext", "RECOVER", "RECOVERY_CHILD_AUTHORIZE", + "SETUP_COMPLETE", + "SETUP_DISPATCH", + "SETUP_MODEL_USAGE_ACTIONS", + "SETUP_OBSERVE", + "SETUP_PREPARE", + "SetupModelCompleteAuthorityFacts", + "SetupModelDispatchAuthorityFacts", + "SetupModelObserveAuthorityFacts", + "SetupModelPrepareAuthorityFacts", + "SetupModelUsageResourceContext", "checker_model_call_resource", "checker_model_prepare_matches", "parse_checker_model_prepare", "project_model_admin_resource", + "setup_model_usage_resource", ) diff --git a/backend/app/modules/authorization/domain/prepared_checker_runtime.py b/backend/app/modules/authorization/domain/prepared_checker_runtime.py index f200cb076..8ab8a30e3 100644 --- a/backend/app/modules/authorization/domain/prepared_checker_runtime.py +++ b/backend/app/modules/authorization/domain/prepared_checker_runtime.py @@ -3,10 +3,11 @@ from uuid import UUID from app.modules.authorization.catalogue import ActionId -from app.modules.authorization.domain.checker_models import ( +from app.modules.authorization.domain.model_usage import ( CHECKER_MODEL_ACTIONS, CheckerModelCallResourceContext, ProjectModelAdministrationResourceContext, + SetupModelUsageResourceContext, checker_model_prepare_matches, parse_checker_model_prepare, ) @@ -46,6 +47,8 @@ def checker_runtime_project_id(action: ActionId, resource: object) -> UUID | Non """Extract project scope only from the exact checker-runtime resource type.""" if action in CHECKER_MODEL_ACTIONS and isinstance(resource, CheckerModelCallResourceContext): return resource.scope_project_id + if action in CHECKER_MODEL_ACTIONS and isinstance(resource, SetupModelUsageResourceContext): + return resource.scope_project_id # Project model administration deliberately consumes system-scoped Operator # authority. The project identifier remains an exact guarded target inside # the resource; it is not the authority scope for this action family. diff --git a/backend/app/modules/authorization/domain/prepared_service.py b/backend/app/modules/authorization/domain/prepared_service.py index 2fa9350ec..11e71762b 100644 --- a/backend/app/modules/authorization/domain/prepared_service.py +++ b/backend/app/modules/authorization/domain/prepared_service.py @@ -6,27 +6,47 @@ from app.core.hashing import canonical_json_hash from app.modules.authorization.catalogue import ActionId -from app.modules.authorization.domain.checker_models import ( +from app.modules.authorization.domain.model_usage import ( CHECKER_MODEL_CALL_ACTIONS, + SETUP_MODEL_USAGE_ACTIONS, CheckerModelCallResourceContext, + SetupModelUsageResourceContext, ) from app.modules.authorization.domain.checker_outputs import ( CHECKER_OUTPUT_ACTIONS, CheckerOutputResourceContext, ) -from app.modules.authorization.domain.post_submit_routing import ROUTE, PostSubmitRoutingResourceContext, parse_post_submit_routing_prepare +from app.modules.authorization.domain.post_submit_routing import ( + ROUTE, + PostSubmitRoutingResourceContext, + parse_post_submit_routing_prepare, +) from app.modules.authorization.domain.post_submit import ( - POST_SUBMIT_ACTIONS, PostSubmitResourceContext, parse_post_submit_prepare, + POST_SUBMIT_ACTIONS, + PostSubmitResourceContext, + parse_post_submit_prepare, ) from app.modules.authorization.domain.pre_submit import ( PRE_SUBMIT_ACTIONS, PreSubmitResourceContext, parse_pre_submit_prepare, ) -from app.modules.authorization.domain.assignment_invalidation import AssignmentInvalidationResourceContext, parse_assignment_invalidation_binding -from app.modules.authorization.domain.revision_expiry import RevisionExpiryResourceContext, parse_revision_expiry_binding -from app.modules.authorization.domain.revision_recovery import RevisionRecoveryResourceContext, parse_revision_recovery_binding -from app.modules.authorization.domain.outbox_dispatch import OutboxDispatchResourceContext, prepared_outbox_digest +from app.modules.authorization.domain.assignment_invalidation import ( + AssignmentInvalidationResourceContext, + parse_assignment_invalidation_binding, +) +from app.modules.authorization.domain.revision_expiry import ( + RevisionExpiryResourceContext, + parse_revision_expiry_binding, +) +from app.modules.authorization.domain.revision_recovery import ( + RevisionRecoveryResourceContext, + parse_revision_recovery_binding, +) +from app.modules.authorization.domain.outbox_dispatch import ( + OutboxDispatchResourceContext, + prepared_outbox_digest, +) from app.modules.authorization.domain.post_policy import PostPolicyResourceContext, DERIVE from app.modules.authorization.domain.guide_compilation import ( ProjectGuideCompilationExecuteResourceContext, @@ -35,7 +55,9 @@ from app.modules.authorization.domain.guide_compilation_projections import ( ProjectGuideProjectionResourceContext, ) -from app.modules.authorization.domain.project_setup_finalization import ProjectSetupFinalizationResourceContext +from app.modules.authorization.domain.project_setup_finalization import ( + ProjectSetupFinalizationResourceContext, +) from app.modules.authorization.runtime import ( AuthorizationResourceContext, PreparedAuthorityScope, @@ -52,6 +74,7 @@ ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, ActionId.PROJECT_SETUP_RUN_UPDATE, DERIVE, + *SETUP_MODEL_USAGE_ACTIONS, } ) @@ -72,9 +95,11 @@ def project_setup_resource_matches( ) -> bool | None: """Validate setup-service facts, or return None for non-setup actions.""" if action_id == DERIVE: - return (type(resource) is PostPolicyResourceContext - and resource.facts.locator.action_id == DERIVE - and resource.scope_project_id == project_id) + return ( + type(resource) is PostPolicyResourceContext + and resource.facts.locator.action_id == DERIVE + and resource.scope_project_id == project_id + ) if action_id is ActionId.PROJECT_SETUP_RUN_UPDATE: return ( isinstance(resource, ProjectSetupFinalizationResourceContext) @@ -114,39 +139,72 @@ def project_setup_resource_matches( and resource.trigger == "automatic_source_ready" and resource.scope_project_id == project_id ) + if action_id in SETUP_MODEL_USAGE_ACTIONS: + return ( + type(resource) is SetupModelUsageResourceContext + and resource.action_id is action_id + and resource.scope_project_id == project_id + ) return None def fixed_service_scope_project(action_id, scope, artifact_resource): """Admit exact setup, dispatcher or artifact scopes without conflating owners.""" - outbox = (action_id in {ActionId.OUTBOX_DISPATCH, ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, - ActionId.TASK_REVISION_EXPIRE, ActionId.TASK_REVISION_RECOVER} - and scope.kind is PreparedAuthorityScopeKind.PROJECT and scope.project_id is not None) - if is_project_setup_scope(action_id, scope) or outbox or ( + outbox = ( action_id - in PRE_SUBMIT_ACTIONS - | POST_SUBMIT_ACTIONS - | CHECKER_MODEL_CALL_ACTIONS - | CHECKER_OUTPUT_ACTIONS - | {ROUTE} + in { + ActionId.OUTBOX_DISPATCH, + ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, + ActionId.TASK_REVISION_EXPIRE, + ActionId.TASK_REVISION_RECOVER, + } and scope.kind is PreparedAuthorityScopeKind.PROJECT and scope.project_id is not None + ) + if ( + is_project_setup_scope(action_id, scope) + or outbox + or ( + action_id + in PRE_SUBMIT_ACTIONS + | POST_SUBMIT_ACTIONS + | CHECKER_MODEL_CALL_ACTIONS + | CHECKER_OUTPUT_ACTIONS + | {ROUTE} + and scope.kind is PreparedAuthorityScopeKind.PROJECT + and scope.project_id is not None + ) ): return scope.project_id - if (artifact_resource is None or scope.kind is not PreparedAuthorityScopeKind.ARTIFACT_INTERNAL - or scope.artifact_resource_type != artifact_resource[0]): - from app.modules.authorization.runtime import PreparedAuthorizationUnsupported, AuthorizationDenialCode + if ( + artifact_resource is None + or scope.kind is not PreparedAuthorityScopeKind.ARTIFACT_INTERNAL + or scope.artifact_resource_type != artifact_resource[0] + ): + from app.modules.authorization.runtime import ( + PreparedAuthorizationUnsupported, + AuthorizationDenialCode, + ) + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.RESOURCE_GUARD_DENIED) return None -def fixed_service_resource_matches(action_id, resource, project_id, artifact_type, artifact_id, expected): +def fixed_service_resource_matches( + action_id, resource, project_id, artifact_type, artifact_id, expected +): """Check the final resource against the exact prepared service scope.""" if action_id is ROUTE: - return type(resource) is PostSubmitRoutingResourceContext and resource.scope_project_id == project_id + return ( + type(resource) is PostSubmitRoutingResourceContext + and resource.scope_project_id == project_id + ) if action_id in POST_SUBMIT_ACTIONS: - return (type(resource) is PostSubmitResourceContext and resource.action_id is action_id - and resource.scope_project_id == project_id) + return ( + type(resource) is PostSubmitResourceContext + and resource.action_id is action_id + and resource.scope_project_id == project_id + ) if action_id in CHECKER_MODEL_CALL_ACTIONS: return ( type(resource) is CheckerModelCallResourceContext @@ -166,36 +224,58 @@ def fixed_service_resource_matches(action_id, resource, project_id, artifact_typ and resource.scope_project_id == project_id ) if action_id is ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE: - return type(resource) is AssignmentInvalidationResourceContext and resource.scope_project_id == project_id + return ( + type(resource) is AssignmentInvalidationResourceContext + and resource.scope_project_id == project_id + ) if action_id is ActionId.TASK_REVISION_EXPIRE: - return type(resource) is RevisionExpiryResourceContext and resource.scope_project_id == project_id + return ( + type(resource) is RevisionExpiryResourceContext + and resource.scope_project_id == project_id + ) if action_id is ActionId.TASK_REVISION_RECOVER: - return type(resource) is RevisionRecoveryResourceContext and resource.scope_project_id == project_id + return ( + type(resource) is RevisionRecoveryResourceContext + and resource.scope_project_id == project_id + ) if action_id is ActionId.OUTBOX_DISPATCH: - return type(resource) is OutboxDispatchResourceContext and resource.scope_project_id == project_id + return ( + type(resource) is OutboxDispatchResourceContext + and resource.scope_project_id == project_id + ) setup = project_setup_resource_matches(action_id, resource, project_id) if setup is not None: return setup - return (expected is not None and isinstance(resource, expected[1]) - and resource.resource_type == artifact_type and resource.resource_id == artifact_id) + return ( + expected is not None + and isinstance(resource, expected[1]) + and resource.resource_type == artifact_type + and resource.resource_id == artifact_id + ) def prepared_request_digest(value): """One canonical request commitment shared by PREP issuance and consumption.""" - return canonical_json_hash({"domain": "workstream.prepared_authorization.request.v1", "request": value}) + return canonical_json_hash( + {"domain": "workstream.prepared_authorization.request.v1", "request": value} + ) def prepared_fixed_service_bindings(action, request, invalid_error): """Keep exact dispatcher and assignment effect commitments with service guards.""" if action is ROUTE: - return {"routing_request": parse_post_submit_routing_prepare(action, request, invalid_error)} + return { + "routing_request": parse_post_submit_routing_prepare(action, request, invalid_error) + } return { - "pre_submit_prepare_context": parse_pre_submit_prepare( + "pre_submit_prepare_context": parse_pre_submit_prepare(action, request, invalid_error), + "post_submit_prepare_context": parse_post_submit_prepare(action, request, invalid_error), + "assignment_invalidation_context": parse_assignment_invalidation_binding( action, request, invalid_error ), - "post_submit_prepare_context": parse_post_submit_prepare(action, request, invalid_error), - "assignment_invalidation_context": parse_assignment_invalidation_binding(action, request, invalid_error), "revision_expiry_context": parse_revision_expiry_binding(action, request, invalid_error), - "revision_recovery_context": parse_revision_recovery_binding(action, request, invalid_error), + "revision_recovery_context": parse_revision_recovery_binding( + action, request, invalid_error + ), "outbox_dispatch_digest": prepared_outbox_digest(action, request, invalid_error), } diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index fabe20753..3e1fb6335 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -24,7 +24,7 @@ PermissionId, ) from app.modules.authorization.domain.guide_manager_resources import guide_manager_resource_denial -from app.modules.authorization.domain import adapter_bindings, artifact_operator, checker_models, checker_registry, checker_retry, contribution_policies, guide_compilation as compilation, post_submit_deliveries +from app.modules.authorization.domain import adapter_bindings, artifact_operator, model_usage as checker_models, checker_registry, checker_retry, contribution_policies, guide_compilation as compilation, post_submit_deliveries from app.modules.authorization.domain.action_groups import ( GUIDE_BOUND_PROJECT_MANAGER_ACTIONS as _GUIDE_BOUND_PROJECT_MANAGER_ACTIONS, CONTEXT_DIGEST_ACTIONS, diff --git a/backend/app/modules/authorization/checker_model_authorization.py b/backend/app/modules/authorization/model_usage_authorization.py similarity index 74% rename from backend/app/modules/authorization/checker_model_authorization.py rename to backend/app/modules/authorization/model_usage_authorization.py index 36019f7cb..2d7aebf37 100644 --- a/backend/app/modules/authorization/checker_model_authorization.py +++ b/backend/app/modules/authorization/model_usage_authorization.py @@ -2,11 +2,11 @@ from __future__ import annotations -from uuid import UUID +from uuid import NAMESPACE_URL, UUID, uuid5 from app.modules.actors.api import ServiceIdentity from app.modules.authorization.catalogue import ActionId -from app.modules.authorization.domain.checker_models import ( +from app.modules.authorization.domain.model_usage import ( BUDGET_CONFIGURE, COMPLETE, DISPATCH, @@ -16,6 +16,11 @@ PROFILE_PROVISION, RECOVER, RECOVERY_CHILD_AUTHORIZE, + SETUP_COMPLETE, + SETUP_DISPATCH, + SETUP_MODEL_USAGE_ACTIONS, + SETUP_OBSERVE, + SETUP_PREPARE, CHECKER_MODEL_CALL_ACTIONS, MODEL_ADMIN_ACTIONS, ModelCallCompleteAuthorityFacts, @@ -25,8 +30,13 @@ ModelCallPrepareAuthorityFacts, ModelCallRecoverAuthorityFacts, ModelCallRecoveryChildAuthorityFacts, + SetupModelCompleteAuthorityFacts, + SetupModelDispatchAuthorityFacts, + SetupModelObserveAuthorityFacts, + SetupModelPrepareAuthorityFacts, checker_model_call_resource, project_model_admin_resource, + setup_model_usage_resource, ) from app.modules.authorization.kernel import AuthorizationService from app.modules.authorization.prepared import PreparedAuthorizationService @@ -41,7 +51,7 @@ PreparedAuthorityScopeKind, ServiceAuthorizationContext, ) -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelActionEvidence, ModelBudgetProvisioningFacts, ModelCallEffectAuthorityFacts, @@ -51,6 +61,9 @@ ModelPaidSlotKey, ModelProfileProvisioningFacts, PreparedModelCallAttempt, + PreparedSetupModelUsageEffect, + SetupModelEffectAuthorityFacts, + SetupModelUsageSlotKey, model_action_digest, ) @@ -244,6 +257,96 @@ async def authorize_complete( "complete", ) + async def authorize_setup_prepare( + self, slot: SetupModelUsageSlotKey, request_sha256: str + ) -> ModelActionEvidence: + """Authorize one stable setup phase/call slot before MODEL locks.""" + facts = SetupModelPrepareAuthorityFacts( + slot=slot, + request_sha256=request_sha256, + ) + return await self._authorize_setup( + SETUP_PREPARE, + facts, + uuid5(NAMESPACE_URL, "workstream:setup-model:" + slot.digest), + "setup_prepare", + ) + + async def authorize_setup_dispatch( + self, + prepared: PreparedSetupModelUsageEffect, + request_evidence: ModelCallEvidence, + ) -> ModelActionEvidence: + """Authorize one verified setup provider dispatch.""" + facts = SetupModelDispatchAuthorityFacts( + prepared=prepared, + request_evidence=request_evidence, + ) + return await self._authorize_setup( + SETUP_DISPATCH, + facts, + prepared.internal_call_request_id, + "setup_dispatch", + ) + + async def authorize_setup_observe( + self, + effect: SetupModelEffectAuthorityFacts, + provider_response_id: str | None, + observation_digest: str, + ) -> ModelActionEvidence: + """Authorize retention of an ambiguous setup provider effect.""" + facts = SetupModelObserveAuthorityFacts( + effect=effect, + provider_response_id=provider_response_id, + observation_digest=observation_digest, + ) + return await self._authorize_setup( + SETUP_OBSERVE, + facts, + effect.internal_call_request_id, + "setup_observe", + ) + + async def authorize_setup_complete( + self, + effect: SetupModelEffectAuthorityFacts, + response_evidence: ModelCallEvidence | None, + terminal_digest: str, + ) -> ModelActionEvidence: + """Authorize exact terminal setup accounting or proven no-effect release.""" + facts = SetupModelCompleteAuthorityFacts( + effect=effect, + response_evidence=response_evidence, + terminal_digest=terminal_digest, + ) + return await self._authorize_setup( + SETUP_COMPLETE, + facts, + effect.internal_call_request_id, + "setup_complete", + ) + + async def _authorize_setup( + self, + action: ActionId, + facts: ( + SetupModelPrepareAuthorityFacts + | SetupModelDispatchAuthorityFacts + | SetupModelObserveAuthorityFacts + | SetupModelCompleteAuthorityFacts + ), + idempotency_key: UUID, + service_action: str, + ) -> ModelActionEvidence: + self._require_setup_service() + return await self._authorize( + action, + setup_model_usage_resource(action, facts), + idempotency_key, + model_action_digest(service_action, facts), + ) + async def _authorize_runtime( self, action: ActionId, @@ -279,6 +382,11 @@ async def _authorize( kind=PreparedAuthorityScopeKind.PROJECT, project_id=resource.scope_project_id, ) + elif action in SETUP_MODEL_USAGE_ACTIONS: + scope = PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=resource.scope_project_id, + ) elif action in MODEL_ADMIN_ACTIONS: scope = PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM) else: @@ -350,5 +458,13 @@ def _require_post_submit_service(self) -> None: ): raise ModelCallUnavailable("model_call_authority_unavailable") + def _require_setup_service(self) -> None: + context = self._authorization._context + if ( + not isinstance(context, ServiceAuthorizationContext) + or context.service_identity is not ServiceIdentity.PROJECT_SETUP + ): + raise ModelCallUnavailable("model_call_authority_unavailable") + __all__ = ("CheckerModelAuthorization",) diff --git a/backend/app/modules/authorization/prepared_admin_authority.py b/backend/app/modules/authorization/prepared_admin_authority.py index 24dfdf9bd..ac836df08 100644 --- a/backend/app/modules/authorization/prepared_admin_authority.py +++ b/backend/app/modules/authorization/prepared_admin_authority.py @@ -4,7 +4,7 @@ from app.modules.authorization.catalogue import ActionAvailability, ActionId, PermissionId from app.modules.authorization.domain import ( adapter_bindings, - checker_models, + model_usage as checker_models, checker_registry, contribution_policies, ) diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 6385fb873..73cad2507 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -27,7 +27,7 @@ from app.modules.authorization.domain.project_create import ProjectCreateResourceContext from app.modules.authorization.domain import ( checker_registry, checker_retry, checker_retry_reads, contribution_reads, - checker_models, checker_outputs, contributor_reads, management_reads, submission_history, task_authority, task_queues, + model_usage as checker_models, checker_outputs, contributor_reads, management_reads, submission_history, task_authority, task_queues, post_submit_deliveries, ) from app.modules.authorization.domain.guide_mutations import ( diff --git a/backend/app/modules/checkers/api/execution.py b/backend/app/modules/checkers/api/execution.py index 4600ce913..205e93e7b 100644 --- a/backend/app/modules/checkers/api/execution.py +++ b/backend/app/modules/checkers/api/execution.py @@ -25,7 +25,7 @@ from app.modules.checkers.api.delivery_reconciliation import ( PostSubmitRequestDeliveryFacts, ) -from app.modules.checkers.api.model_calls import ModelRecoveryCandidate +from app.modules.model_usage.api import ModelRecoveryCandidate REQUEST_EVENT = "PostSubmissionEvaluationRequested" COMPLETION_EVENT = "PostSubmissionEvaluationCompleted" diff --git a/backend/app/modules/checkers/api/external_policy.py b/backend/app/modules/checkers/api/external_policy.py index 71872248b..beb57ead9 100644 --- a/backend/app/modules/checkers/api/external_policy.py +++ b/backend/app/modules/checkers/api/external_policy.py @@ -21,7 +21,7 @@ ExternalCheckerRegistryPort, external_checker_json_hash, ) -from app.modules.checkers.api.model_calls import ModelProfileReaderPort +from app.modules.model_usage.api import ModelProfileReaderPort from app.modules.checkers.api.model_profiles import ( ModelProfileConfigurationError, configured_model_profile_selector, diff --git a/backend/app/modules/checkers/api/model_grants.py b/backend/app/modules/checkers/api/model_grants.py index 3058e81e9..acb5a83fd 100644 --- a/backend/app/modules/checkers/api/model_grants.py +++ b/backend/app/modules/checkers/api/model_grants.py @@ -1,32 +1,8 @@ -"""Private invocation grants linking isolated project images to the model proxy.""" +"""Compatibility aliases for shared private model invocation grants.""" -from dataclasses import dataclass, field -from typing import Protocol -from uuid import UUID - -from app.modules.checkers.api.model_calls import ( - ModelActionEvidence, - ModelCallExecutionFacts, - ModelPaidSlotKey, +from app.modules.model_usage.api import ( + ModelInvocationGrantAuthorityPort, + ModelInvocationGrantFacts, ) - -class ModelInvocationGrantAuthorityPort(Protocol): - """Issue one bounded callback capability without authorizing provider spend.""" - - async def authorize_grant( - self, execution: ModelCallExecutionFacts, slot: ModelPaidSlotKey, - external_request_digest: str, token_hash: str, - ) -> ModelActionEvidence: - """Authorize the exact invocation, request binding and opaque token hash.""" - - -@dataclass(frozen=True, slots=True) -class ModelInvocationGrantFacts: - """Immutable owner facts selected privately by the proxy, never image input.""" - - token_hash: str = field(repr=False) - external_request_digest: str - execution: ModelCallExecutionFacts - slot: ModelPaidSlotKey - registry_entry_id: UUID +__all__ = ("ModelInvocationGrantAuthorityPort", "ModelInvocationGrantFacts") diff --git a/backend/app/modules/checkers/api/model_profiles.py b/backend/app/modules/checkers/api/model_profiles.py index 1b6b941cf..1fc5f47d3 100644 --- a/backend/app/modules/checkers/api/model_profiles.py +++ b/backend/app/modules/checkers/api/model_profiles.py @@ -11,7 +11,7 @@ ExternalCheckerPhase, ExternalCheckerRegistryEntry, ) -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelCallUnavailable, ModelProfileReaderPort, ModelProfileSelector, @@ -78,8 +78,6 @@ async def require_model_profile_configuration( profile = await profiles.read_exact( selector, project_id=project_id, - registry_entry_id=registry.registry_entry_id, - registry_entry_digest=registry.entry_digest, ) except ModelCallUnavailable as exc: # Registry/persistence details remain concealed at the policy boundary. @@ -87,8 +85,6 @@ async def require_model_profile_configuration( if ( profile.selector != selector or profile.spec.project_id != project_id - or profile.spec.registry_entry_id != registry.registry_entry_id - or profile.spec.registry_entry_digest != registry.entry_digest ): raise ModelProfileConfigurationError("model profile is unavailable") return selector diff --git a/backend/app/modules/checkers/api/output_custody.py b/backend/app/modules/checkers/api/output_custody.py index d7c156011..1be86b329 100644 --- a/backend/app/modules/checkers/api/output_custody.py +++ b/backend/app/modules/checkers/api/output_custody.py @@ -7,7 +7,7 @@ from pydantic import Field, StrictInt, model_validator -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelCallExecutionFacts, ModelPaidSlotKey, ModelCallEvidence, diff --git a/backend/app/modules/checkers/checker_retry.py b/backend/app/modules/checkers/checker_retry.py index cbdcea493..44b345479 100644 --- a/backend/app/modules/checkers/checker_retry.py +++ b/backend/app/modules/checkers/checker_retry.py @@ -12,30 +12,49 @@ from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id from app.modules.checkers.api.execution import ( - CheckerExecutionUnavailable, CheckerRequestConflict, CheckerRetryAuditPort, - CheckerRetryAuthorizationPort, CheckerRetryCommand, CheckerRetryDelivery, - CheckerRetryDiscoveryAuthorizationPort, CheckerRetryAuthorityDenied, + CheckerExecutionUnavailable, + CheckerRequestConflict, + CheckerRetryAuditPort, + CheckerRetryAuthorizationPort, + CheckerRetryCommand, + CheckerRetryDelivery, + CheckerRetryDiscoveryAuthorizationPort, + CheckerRetryAuthorityDenied, CheckerRetryDiscoverySource, CheckerRetryEventExpectation, CheckerRetryOperationUnavailable, - CheckerRetryPreparation, CheckerRetryResult, CheckerRetrySource, + CheckerRetryPreparation, + CheckerRetryResult, + CheckerRetrySource, CheckerRetrySourceUnavailable, - CheckerRetrySourceItem, CheckerRetrySourcePage, CheckerRetrySourcePosition, - CheckerRetrySourceQuery, CheckerRetrySuccessor, EvaluationTaskGuard, + CheckerRetrySourceItem, + CheckerRetrySourcePage, + CheckerRetrySourcePosition, + CheckerRetrySourceQuery, + CheckerRetrySuccessor, + EvaluationTaskGuard, ) from app.modules.checkers.api.evaluation_contract import PostSubmissionExecutionRequest from app.modules.checkers.api.selected_post_submit import make_selected_post_submit_request from app.modules.checkers.execution_coordination import EvaluationCoordinator from app.modules.checkers.execution_repository import ( - require_transaction, stored_request, stored_result, + require_transaction, + stored_request, + stored_result, ) from app.modules.checkers.models import ( - CheckerRetryReceipt, CheckerRun, CheckerSubmissionFence, + CheckerRetryReceipt, + CheckerRun, + CheckerSubmissionFence, ) -from app.modules.checkers.model_calls.service import model_recovery_candidates +from app.modules.model_usage.api import ModelRecoverySourcePort from app.modules.outbox.api import ( - OutboxAppendInput, OutboxAppendPort, OutboxAppendResult, - OutboxIdempotencyConflict, OutboxInputError, OutboxPersistenceError, + OutboxAppendInput, + OutboxAppendPort, + OutboxAppendResult, + OutboxIdempotencyConflict, + OutboxInputError, + OutboxPersistenceError, ) @@ -44,18 +63,19 @@ def require_finalized_infrastructure_source( ) -> tuple[PostSubmissionExecutionRequest, CheckerRetrySource]: """Require a real terminal result/finalization before any new Operator effect.""" if ( - run.status != "infrastructure_failed" or run.finalize_evidence_id is None - or run.result_json is None or run.result_digest is None - or run.completion_event_id is not None or run.failure_code is None + run.status != "infrastructure_failed" + or run.finalize_evidence_id is None + or run.result_json is None + or run.result_digest is None + or run.completion_event_id is not None + or run.failure_code is None ): raise CheckerRetrySourceUnavailable("checker_retry_source_unavailable") try: request = stored_request(run) result = stored_result(run) except CheckerExecutionUnavailable as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_evidence_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_evidence_unavailable") from exc if result.outcome != "infrastructure_failed": raise CheckerRetrySourceUnavailable("checker_retry_source_unavailable") if ( @@ -65,26 +85,30 @@ def require_finalized_infrastructure_source( raise CheckerRetryOperationUnavailable("checker_retry_evidence_unavailable") try: source = CheckerRetrySource( - project_id=request.project_id, task_id=request.task_id, - assignment_id=request.assignment_id, submission_id=request.submission_id, + project_id=request.project_id, + task_id=request.task_id, + assignment_id=request.assignment_id, + submission_id=request.submission_id, submission_version=request.submission_version, - source_run_id=UUID(run.id), source_request_id=request.evaluation_request_id, + source_run_id=UUID(run.id), + source_request_id=request.evaluation_request_id, source_request_digest=request.request_sha256, - source_result_id=UUID(run.result_id), source_result_digest=run.result_digest, + source_result_id=UUID(run.result_id), + source_result_digest=run.result_digest, source_finalize_evidence_id=UUID(run.finalize_evidence_id), - source_generation=run.evaluation_generation, failure_code=run.failure_code, + source_generation=run.evaluation_generation, + failure_code=run.failure_code, ) except (TypeError, ValueError) as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_evidence_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_evidence_unavailable") from exc return request, source def _source_from_receipt(receipt: CheckerRetryReceipt) -> CheckerRetrySource: try: return CheckerRetrySource( - project_id=UUID(receipt.project_id), task_id=UUID(receipt.task_id), + project_id=UUID(receipt.project_id), + task_id=UUID(receipt.task_id), assignment_id=UUID(receipt.assignment_id), submission_id=UUID(receipt.submission_id), submission_version=receipt.submission_version, @@ -94,12 +118,11 @@ def _source_from_receipt(receipt: CheckerRetryReceipt) -> CheckerRetrySource: source_result_id=UUID(receipt.source_result_id), source_result_digest=receipt.source_result_digest, source_finalize_evidence_id=UUID(receipt.source_finalize_evidence_id), - source_generation=receipt.source_generation, failure_code=receipt.failure_code, + source_generation=receipt.source_generation, + failure_code=receipt.failure_code, ) except (TypeError, ValueError) as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_replay_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_replay_unavailable") from exc def _successor_from_receipt(receipt: CheckerRetryReceipt) -> CheckerRetrySuccessor: @@ -114,29 +137,37 @@ def _successor_from_receipt(receipt: CheckerRetryReceipt) -> CheckerRetrySuccess audit_event_id=UUID(receipt.audit_event_id), ) except (TypeError, ValueError) as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_replay_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_replay_unavailable") from exc def retry_request_event( - source: CheckerRetrySource, *, receipt_id: UUID, request_id: UUID, - request_digest: str, run_id: UUID, result_id: UUID, + source: CheckerRetrySource, + *, + receipt_id: UUID, + request_id: UUID, + request_digest: str, + run_id: UUID, + result_id: UUID, ) -> OutboxAppendInput: """One v1 request event schema for creation and later exact delivery.""" return OutboxAppendInput( - event_type="PostSubmissionEvaluationRequested", event_version=1, - aggregate_type="submission", aggregate_id=source.submission_id, + event_type="PostSubmissionEvaluationRequested", + event_version=1, + aggregate_type="submission", + aggregate_id=source.submission_id, project_id=source.project_id, correlation_id=str(source.source_run_id), idempotency_key="checker-retry:" + str(source.source_run_id), payload={ - "project_id": str(source.project_id), "task_id": str(source.task_id), + "project_id": str(source.project_id), + "task_id": str(source.task_id), "submission_id": str(source.submission_id), "submission_version": source.submission_version, - "request_id": str(request_id), "request_digest": request_digest, + "request_id": str(request_id), + "request_digest": request_digest, "evaluation_generation": source.source_generation + 1, - "attempt_id": str(run_id), "result_id": str(result_id), + "attempt_id": str(run_id), + "result_id": str(result_id), "source_run_id": str(source.source_run_id), "source_result_digest": source.source_result_digest, "retry_receipt_id": str(receipt_id), @@ -145,39 +176,56 @@ def retry_request_event( async def _append_retry_event( - events: OutboxAppendPort, source: CheckerRetrySource, *, receipt_id: UUID, - request_id: UUID, request_digest: str, run_id: UUID, result_id: UUID, + events: OutboxAppendPort, + source: CheckerRetrySource, + *, + receipt_id: UUID, + request_id: UUID, + request_digest: str, + run_id: UUID, + result_id: UUID, ) -> OutboxAppendResult: """Normalize exact OUTBOX contract failures into retry-owner unavailability.""" try: - return await events.append(retry_request_event( - source, receipt_id=receipt_id, request_id=request_id, - request_digest=request_digest, run_id=run_id, result_id=result_id, - )) + return await events.append( + retry_request_event( + source, + receipt_id=receipt_id, + request_id=request_id, + request_digest=request_digest, + run_id=run_id, + result_id=result_id, + ) + ) except ( OutboxIdempotencyConflict, OutboxInputError, OutboxPersistenceError, ) as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_outbox_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_outbox_unavailable") from exc class CheckerRetryService: """Compose existing TASK, AUTH, CHECKERS, OUTBOX and AUDIT owner operations.""" def __init__( - self, session: AsyncSession, *, actor_profile_id: UUID, - tasks: EvaluationTaskGuard, evaluations: EvaluationCoordinator, + self, + session: AsyncSession, + *, + actor_profile_id: UUID, + tasks: EvaluationTaskGuard, + evaluations: EvaluationCoordinator, authorization: CheckerRetryAuthorizationPort, discovery: CheckerRetryDiscoveryAuthorizationPort, - events: OutboxAppendPort, audit: CheckerRetryAuditPort, + events: OutboxAppendPort, + audit: CheckerRetryAuditPort, + model_recovery: ModelRecoverySourcePort, ) -> None: self._session, self._actor_profile_id = session, actor_profile_id self._tasks, self._evaluations = tasks, evaluations self._authorization, self._discovery = authorization, discovery self._events, self._audit = events, audit + self._model_recovery = model_recovery async def discover(self, query: CheckerRetrySourceQuery) -> CheckerRetrySourcePage: """List only retained finalized infrastructure failures under fresh authority.""" @@ -201,9 +249,7 @@ async def discover(self, query: CheckerRetrySourceQuery) -> CheckerRetrySourcePa CheckerRun.completed_at.label("completed_at"), CheckerRetryReceipt.id.label("retry_receipt_id"), CheckerRetryReceipt.successor_request_id.label("successor_request_id"), - CheckerRetryReceipt.successor_request_digest.label( - "successor_request_digest" - ), + CheckerRetryReceipt.successor_request_digest.label("successor_request_digest"), CheckerRetryReceipt.successor_run_id.label("successor_run_id"), CheckerRetryReceipt.successor_result_id.label("successor_result_id"), CheckerRetryReceipt.successor_generation.label("successor_generation"), @@ -237,12 +283,15 @@ async def discover(self, query: CheckerRetrySourceQuery) -> CheckerRetrySourcePa > tuple_(after.created_at, str(after.source_run_id)) ) rows = ( - await self._session.execute( - statement.order_by(CheckerRun.created_at, CheckerRun.id).limit(query.limit + 1) + ( + await self._session.execute( + statement.order_by(CheckerRun.created_at, CheckerRun.id).limit(query.limit + 1) + ) ) - ).mappings().all() - recovery_candidates = await model_recovery_candidates( - self._session, + .mappings() + .all() + ) + recovery_candidates = await self._model_recovery.recovery_candidates( tuple(UUID(row.source_run_id) for row in rows[: query.limit]), ) items = [] @@ -277,23 +326,19 @@ async def discover(self, query: CheckerRetrySourceQuery) -> CheckerRetrySourcePa ), ) else: - state = ( - "eligible" - if row.current_run_id == row.source_run_id - else "not_current" - ) + state = "eligible" if row.current_run_id == row.source_run_id else "not_current" receipt_id, successor = None, None - items.append(CheckerRetrySourceItem( - source=source, - recovery_state=state, - created_at=row.created_at, - completed_at=row.completed_at, - retry_receipt_id=receipt_id, - successor=successor, - model_recovery_candidates=recovery_candidates.get( - UUID(row.source_run_id), () - ), - )) + items.append( + CheckerRetrySourceItem( + source=source, + recovery_state=state, + created_at=row.created_at, + completed_at=row.completed_at, + retry_receipt_id=receipt_id, + successor=successor, + model_recovery_candidates=recovery_candidates.get(UUID(row.source_run_id), ()), + ) + ) next_cursor = None if len(rows) > query.limit: last = items[-1] @@ -315,34 +360,38 @@ async def retry(self, command: CheckerRetryCommand) -> CheckerRetryResult: require_transaction(self._session) command = CheckerRetryCommand.model_validate(command) # Detached selectors only: no CHECKERS lock, AUTH custody or decision yet. - existing = await self._session.scalar(select(CheckerRetryReceipt).where( - CheckerRetryReceipt.actor_profile_id == str(self._actor_profile_id), - CheckerRetryReceipt.request_key == str(command.request_key), - ).execution_options(populate_existing=True)) + existing = await self._session.scalar( + select(CheckerRetryReceipt) + .where( + CheckerRetryReceipt.actor_profile_id == str(self._actor_profile_id), + CheckerRetryReceipt.request_key == str(command.request_key), + ) + .execution_options(populate_existing=True) + ) if existing is not None and existing.project_id != str(command.project_id): raise CheckerRetrySourceUnavailable("checker_retry_source_unavailable") if existing is not None: try: source_id = UUID(existing.source_run_id) except (TypeError, ValueError) as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_replay_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_replay_unavailable") from exc else: source_id = command.source_run_id - source_run = await self._session.scalar(select(CheckerRun).where( - CheckerRun.id == str(source_id), - CheckerRun.project_id == str(command.project_id), - CheckerRun.phase == "post_submission", - ).execution_options(populate_existing=True)) + source_run = await self._session.scalar( + select(CheckerRun) + .where( + CheckerRun.id == str(source_id), + CheckerRun.project_id == str(command.project_id), + CheckerRun.phase == "post_submission", + ) + .execution_options(populate_existing=True) + ) if source_run is None: raise CheckerRetrySourceUnavailable("checker_retry_source_unavailable") try: source_request = stored_request(source_run) except CheckerExecutionUnavailable as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_evidence_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_evidence_unavailable") from exc # Both branches first hold the same historical TASK owner rows. A # contender that saw no detached receipt must reread it after this # lock: the first transaction may have committed while it waited. @@ -355,10 +404,14 @@ async def retry(self, command: CheckerRetryCommand) -> CheckerRetryResult: else CheckerRetrySourceUnavailable ) raise unavailable("checker_retry_scope_unavailable") from exc - existing = await self._session.scalar(select(CheckerRetryReceipt).where( - CheckerRetryReceipt.actor_profile_id == str(self._actor_profile_id), - CheckerRetryReceipt.request_key == str(command.request_key), - ).execution_options(populate_existing=True)) + existing = await self._session.scalar( + select(CheckerRetryReceipt) + .where( + CheckerRetryReceipt.actor_profile_id == str(self._actor_profile_id), + CheckerRetryReceipt.request_key == str(command.request_key), + ) + .execution_options(populate_existing=True) + ) if existing is not None and ( existing.source_run_id != str(command.source_run_id) or existing.reason != command.reason @@ -368,14 +421,16 @@ async def retry(self, command: CheckerRetryCommand) -> CheckerRetryResult: try: await self._tasks.lock_terminal_retry_scope(source_request) except CheckerExecutionUnavailable as exc: - raise CheckerRetrySourceUnavailable( - "checker_retry_source_unavailable" - ) from exc - source_run = await self._session.scalar(select(CheckerRun).where( - CheckerRun.id == str(source_id), - CheckerRun.project_id == str(command.project_id), - CheckerRun.phase == "post_submission", - ).execution_options(populate_existing=True)) + raise CheckerRetrySourceUnavailable("checker_retry_source_unavailable") from exc + source_run = await self._session.scalar( + select(CheckerRun) + .where( + CheckerRun.id == str(source_id), + CheckerRun.project_id == str(command.project_id), + CheckerRun.phase == "post_submission", + ) + .execution_options(populate_existing=True) + ) if source_run is None: raise CheckerRetrySourceUnavailable("checker_retry_source_unavailable") locked_request, source = require_finalized_infrastructure_source(source_run) @@ -385,21 +440,28 @@ async def retry(self, command: CheckerRetryCommand) -> CheckerRetryResult: return await self._replay(command, existing, source) preparation = CheckerRetryPreparation( actor_profile_id=self._actor_profile_id, - request_key=command.request_key, reason=command.reason, source=source, + request_key=command.request_key, + reason=command.reason, + source=source, ) handle = await self._authorization.prepare(preparation) try: request_fields = { - **source_request.model_dump(exclude={ - "evaluation_request_id", "evaluation_generation", "request_sha256", - }), + **source_request.model_dump( + exclude={ + "evaluation_request_id", + "evaluation_generation", + "request_sha256", + } + ), "evaluation_request_id": new_record_id(), "evaluation_generation": source.source_generation + 1, } next_request = make_selected_post_submit_request(**request_fields) try: reserved = await self._evaluations._reserve_after_task_lock( - next_request, terminal_source_id=source.source_run_id, + next_request, + terminal_source_id=source.source_run_id, ) except CheckerRetrySourceUnavailable: raise @@ -412,29 +474,36 @@ async def retry(self, command: CheckerRetryCommand) -> CheckerRetryResult: or reserved.request_digest != next_request.request_sha256 or reserved.evaluation_generation != source.source_generation + 1 ): - raise CheckerRetryOperationUnavailable( - "checker_retry_reservation_unavailable" - ) + raise CheckerRetryOperationUnavailable("checker_retry_reservation_unavailable") receipt_id = new_record_id() event = await _append_retry_event( - self._events, source, receipt_id=receipt_id, + self._events, + source, + receipt_id=receipt_id, request_id=reserved.request_id, request_digest=reserved.request_digest, - run_id=reserved.attempt_id, result_id=reserved.result_id, + run_id=reserved.attempt_id, + result_id=reserved.result_id, ) successor = CheckerRetrySuccessor( - request_id=reserved.request_id, request_digest=reserved.request_digest, - run_id=reserved.attempt_id, result_id=reserved.result_id, + request_id=reserved.request_id, + request_digest=reserved.request_digest, + run_id=reserved.attempt_id, + result_id=reserved.result_id, generation=reserved.evaluation_generation, - event_id=event.event_id, audit_event_id=new_record_id(), + event_id=event.event_id, + audit_event_id=new_record_id(), ) authority = await self._authorization.consume(handle, preparation, successor) await self._audit.record(preparation, successor, authority) receipt = CheckerRetryReceipt( - id=str(receipt_id), actor_profile_id=str(self._actor_profile_id), + id=str(receipt_id), + actor_profile_id=str(self._actor_profile_id), identity_link_id=str(authority.identity_link_id), - request_key=str(command.request_key), reason=command.reason, - project_id=str(source.project_id), task_id=str(source.task_id), + request_key=str(command.request_key), + reason=command.reason, + project_id=str(source.project_id), + task_id=str(source.task_id), assignment_id=str(source.assignment_id), submission_id=str(source.submission_id), submission_version=source.submission_version, @@ -444,7 +513,8 @@ async def retry(self, command: CheckerRetryCommand) -> CheckerRetryResult: source_result_id=str(source.source_result_id), source_result_digest=source.source_result_digest, source_finalize_evidence_id=str(source.source_finalize_evidence_id), - source_generation=source.source_generation, failure_code=source.failure_code, + source_generation=source.source_generation, + failure_code=source.failure_code, successor_run_id=str(successor.run_id), successor_request_id=str(successor.request_id), successor_request_digest=successor.request_digest, @@ -459,7 +529,9 @@ async def retry(self, command: CheckerRetryCommand) -> CheckerRetryResult: self._session.add(receipt) await self._session.flush() return CheckerRetryResult( - receipt_id=receipt_id, source=source, successor=successor, + receipt_id=receipt_id, + source=source, + successor=successor, authorization_decision_id=authority.decision_id, ) finally: @@ -474,48 +546,63 @@ async def restage_discovery_denial(self, error: Exception) -> bool: return await self._discovery.restage_denial(error) async def _replay( - self, command: CheckerRetryCommand, detached: CheckerRetryReceipt, + self, + command: CheckerRetryCommand, + detached: CheckerRetryReceipt, source: CheckerRetrySource, ) -> CheckerRetryResult: preparation = CheckerRetryPreparation( - actor_profile_id=self._actor_profile_id, request_key=command.request_key, - reason=command.reason, source=source, + actor_profile_id=self._actor_profile_id, + request_key=command.request_key, + reason=command.reason, + source=source, ) handle = await self._authorization.prepare(preparation) try: - receipt = await self._session.scalar(select(CheckerRetryReceipt).where( - CheckerRetryReceipt.id == detached.id, - CheckerRetryReceipt.actor_profile_id == str(self._actor_profile_id), - CheckerRetryReceipt.request_key == str(command.request_key), - ).with_for_update().execution_options(populate_existing=True)) - if receipt is None or _source_from_receipt(receipt) != source or receipt.reason != command.reason: - raise CheckerRetryOperationUnavailable( - "checker_retry_replay_unavailable" + receipt = await self._session.scalar( + select(CheckerRetryReceipt) + .where( + CheckerRetryReceipt.id == detached.id, + CheckerRetryReceipt.actor_profile_id == str(self._actor_profile_id), + CheckerRetryReceipt.request_key == str(command.request_key), ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if ( + receipt is None + or _source_from_receipt(receipt) != source + or receipt.reason != command.reason + ): + raise CheckerRetryOperationUnavailable("checker_retry_replay_unavailable") successor = _successor_from_receipt(receipt) delivery = await read_retry_delivery( - self._session, self._evaluations, - project_id=source.project_id, submission_id=source.submission_id, + self._session, + self._evaluations, + project_id=source.project_id, + submission_id=source.submission_id, event_id=successor.event_id, ) # The reader already binds the retained run/result/generation to this receipt. if delivery is None or delivery.request.evaluation_request_id != successor.request_id: - raise CheckerRetryOperationUnavailable( - "checker_retry_replay_unavailable" - ) + raise CheckerRetryOperationUnavailable("checker_retry_replay_unavailable") try: identity_link_id = UUID(receipt.identity_link_id) decision_id = UUID(receipt.authorization_decision_id) receipt_id = UUID(receipt.id) except (TypeError, ValueError) as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_replay_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_replay_unavailable") from exc await self._authorization.validate_replay( - handle, preparation, successor, identity_link_id, decision_id, + handle, + preparation, + successor, + identity_link_id, + decision_id, ) return CheckerRetryResult( - receipt_id=receipt_id, source=source, successor=successor, + receipt_id=receipt_id, + source=source, + successor=successor, authorization_decision_id=decision_id, ) finally: @@ -523,27 +610,35 @@ async def _replay( async def read_retry_delivery( - session: AsyncSession, evaluations: EvaluationCoordinator, *, - project_id: UUID, submission_id: UUID, event_id: UUID, + session: AsyncSession, + evaluations: EvaluationCoordinator, + *, + project_id: UUID, + submission_id: UUID, + event_id: UUID, ) -> CheckerRetryDelivery | None: """Expose only a committed exact receipt through CHECKERS's existing port.""" - receipt = await session.scalar(select(CheckerRetryReceipt).where( - CheckerRetryReceipt.project_id == str(project_id), - CheckerRetryReceipt.submission_id == str(submission_id), - CheckerRetryReceipt.event_id == event_id, - ).execution_options(populate_existing=True)) + receipt = await session.scalar( + select(CheckerRetryReceipt) + .where( + CheckerRetryReceipt.project_id == str(project_id), + CheckerRetryReceipt.submission_id == str(submission_id), + CheckerRetryReceipt.event_id == event_id, + ) + .execution_options(populate_existing=True) + ) if receipt is None: return None source, successor = _source_from_receipt(receipt), _successor_from_receipt(receipt) try: stored = await evaluations.read_reserved_evaluation( - project_id=source.project_id, task_id=source.task_id, - submission_id=source.submission_id, request_id=successor.request_id, + project_id=source.project_id, + task_id=source.task_id, + submission_id=source.submission_id, + request_id=successor.request_id, ) except CheckerExecutionUnavailable as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_delivery_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_delivery_unavailable") from exc if ( stored.reservation.request_digest != successor.request_digest or stored.reservation.attempt_id != successor.run_id @@ -556,25 +651,31 @@ async def read_retry_delivery( try: receipt_id = UUID(receipt.id) except (TypeError, ValueError) as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_delivery_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_delivery_unavailable") from exc expected = retry_request_event( - source, receipt_id=receipt_id, request_id=successor.request_id, + source, + receipt_id=receipt_id, + request_id=successor.request_id, request_digest=successor.request_digest, - run_id=successor.run_id, result_id=successor.result_id, + run_id=successor.run_id, + result_id=successor.result_id, ) return CheckerRetryDelivery( request=stored.request, expected_event=CheckerRetryEventExpectation( - event_type=expected.event_type, event_version=expected.event_version, - aggregate_type=expected.aggregate_type, aggregate_id=expected.aggregate_id, + event_type=expected.event_type, + event_version=expected.event_version, + aggregate_type=expected.aggregate_type, + aggregate_id=expected.aggregate_id, correlation_id=expected.correlation_id, causation_event_id=expected.causation_event_id, idempotency_key=expected.idempotency_key, payload_json=json.dumps( - expected.payload, sort_keys=True, separators=(",", ":"), - ensure_ascii=False, allow_nan=False, + expected.payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, ), payload_digest=canonical_json_hash(expected.payload), ), diff --git a/backend/app/modules/checkers/model_calls/__init__.py b/backend/app/modules/checkers/model_calls/__init__.py deleted file mode 100644 index ef605b5a6..000000000 --- a/backend/app/modules/checkers/model_calls/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Durable paid model-call ownership for external project checker images.""" - -from app.modules.checkers.model_calls.service import ModelCallService - -__all__ = ("ModelCallService",) diff --git a/backend/app/modules/checkers/model_calls/repository.py b/backend/app/modules/checkers/model_calls/repository.py deleted file mode 100644 index e2f5c5115..000000000 --- a/backend/app/modules/checkers/model_calls/repository.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Flush-only persistence and lock order for project model-call ownership.""" - -from __future__ import annotations - -from uuid import UUID - -from sqlalchemy import func, select, text -from sqlalchemy.ext.asyncio import AsyncSession - -from app.modules.checkers.api.model_calls import ModelPaidSlotKey, ModelCallUnavailable -from app.modules.checkers.model_calls.models import ( - CheckerModelBudgetAccount, - CheckerModelBudgetConfiguration, - CheckerModelCallAttempt, - CheckerModelCallRecovery, - CheckerModelCallSlot, - CheckerModelProfile, -) -from app.modules.checkers.models import ExternalCheckerRegistryEntryRecord - - -def require_model_call_transaction(session: AsyncSession) -> None: - """Require one caller-owned root transaction and reject nested ownership.""" - transaction = session.sync_session.get_transaction() - if transaction is None or not transaction.is_active or session.in_nested_transaction(): - raise ModelCallUnavailable("model_call_transaction_unavailable") - - -class ModelCallRepository: - """Own model-call row locks without committing the caller's transaction.""" - - def __init__(self, session: AsyncSession) -> None: - """Bind all reads, writes, and locks to the caller-owned session.""" - self._session = session - - async def now(self): - """Read the authoritative database clock inside the current transaction.""" - return await self._session.scalar(select(func.clock_timestamp())) - - async def advisory(self, key: str) -> None: - """Take a transaction-scoped advisory lock for one canonical owner key.""" - await self._session.execute( - text("select pg_advisory_xact_lock(hashtextextended(:key, 0))"), - {"key": key}, - ) - - async def lock_profile_scopes( - self, operation_id: UUID, project_id: UUID, profile_version: int - ) -> None: - """Serialize profile idempotency and project version allocation in order.""" - for key in ( - f"checker-model-profile-operation:{operation_id}", - f"checker-model-profile:{project_id}", - ): - await self.advisory(key) - - async def is_exact_post_submit_registry_entry( - self, registry_entry_id: UUID, registry_entry_digest: str - ) -> bool: - """Resolve the profile target against immutable POST registry custody.""" - entry_id = await self._session.scalar( - select(ExternalCheckerRegistryEntryRecord.id).where( - ExternalCheckerRegistryEntryRecord.id == registry_entry_id, - ExternalCheckerRegistryEntryRecord.entry_digest == registry_entry_digest, - ExternalCheckerRegistryEntryRecord.phase == "post_submit", - ) - ) - return entry_id is not None - - async def profile_by_operation(self, operation_id: UUID): - """Return the immutable profile previously written for an operation.""" - return await self._session.scalar( - select(CheckerModelProfile).where(CheckerModelProfile.operation_id == str(operation_id)) - ) - - async def profile_by_version(self, project_id: UUID, profile_version: int): - """Return the profile occupying one project-scoped version, if present.""" - return await self._session.scalar( - select(CheckerModelProfile).where( - CheckerModelProfile.project_id == str(project_id), - CheckerModelProfile.profile_version == profile_version, - ) - ) - - async def latest_profile(self, project_id: UUID): - """Return the highest immutable profile version currently stored for a project.""" - return await self._session.scalar( - select(CheckerModelProfile) - .where(CheckerModelProfile.project_id == str(project_id)) - .order_by(CheckerModelProfile.profile_version.desc()) - .limit(1) - ) - - async def profile_exact(self, profile_id: UUID, profile_version: int, profile_digest: str): - """Resolve a profile only when its ID, version, and digest all match.""" - return await self._session.scalar( - select(CheckerModelProfile).where( - CheckerModelProfile.id == str(profile_id), - CheckerModelProfile.profile_version == profile_version, - CheckerModelProfile.profile_digest == profile_digest, - ) - ) - - async def lock_budget_scope(self, project_id: UUID) -> None: - """Serialize lifetime budget mutations for one project.""" - await self.advisory(f"checker-model-budget:{project_id}") - - async def budget_account(self, project_id: UUID, *, lock: bool = False): - """Load the project lifetime account, optionally taking its row lock.""" - query = select(CheckerModelBudgetAccount).where( - CheckerModelBudgetAccount.project_id == str(project_id) - ) - if lock: - query = query.with_for_update() - return await self._session.scalar(query.execution_options(populate_existing=True)) - - async def budget_configuration_by_operation(self, operation_id: UUID): - """Return the immutable budget configuration written for an operation.""" - return await self._session.scalar( - select(CheckerModelBudgetConfiguration).where( - CheckerModelBudgetConfiguration.operation_id == str(operation_id) - ) - ) - - async def budget_configuration_by_version(self, project_id: UUID, version: int): - """Return one exact project budget version, if it exists.""" - return await self._session.scalar( - select(CheckerModelBudgetConfiguration).where( - CheckerModelBudgetConfiguration.project_id == str(project_id), - CheckerModelBudgetConfiguration.configuration_version == version, - ) - ) - - async def current_budget_configuration(self, account: CheckerModelBudgetAccount): - """Refresh the configuration selected by the locked lifetime account.""" - return await self._session.get( - CheckerModelBudgetConfiguration, - account.current_configuration_id, - populate_existing=True, - ) - - @staticmethod - def _slot_predicates(key: ModelPaidSlotKey): - """Build the complete paid-slot identity predicate without partial matching.""" - return ( - CheckerModelCallSlot.project_id == str(key.project_id), - CheckerModelCallSlot.assignment_id == str(key.assignment_id), - CheckerModelCallSlot.submission_id == str(key.submission_id), - CheckerModelCallSlot.submission_version == key.submission_version, - CheckerModelCallSlot.requirement_id == key.requirement_id, - CheckerModelCallSlot.registry_entry_digest == key.registry_entry_digest, - CheckerModelCallSlot.configuration_sha256 == key.configuration_sha256, - CheckerModelCallSlot.profile_id == str(key.profile_id), - CheckerModelCallSlot.profile_version == key.profile_version, - CheckerModelCallSlot.profile_digest == key.profile_digest, - CheckerModelCallSlot.slot_index == key.slot_index, - ) - - async def lock_slot_scope(self, key: ModelPaidSlotKey) -> None: - """Serialize creation and state transitions for one exact paid slot.""" - await self.advisory("checker-model-call-slot:" + key.digest) - - async def slot(self, key: ModelPaidSlotKey, *, lock: bool = False): - """Load an exact paid slot and optionally lock it for mutation.""" - query = select(CheckerModelCallSlot).where(*self._slot_predicates(key)) - if lock: - query = query.with_for_update() - return await self._session.scalar(query.execution_options(populate_existing=True)) - - async def latest_attempt(self, slot_id: str, *, lock: bool = False): - """Load the greatest attempt number for a slot, optionally for update.""" - query = ( - select(CheckerModelCallAttempt) - .where(CheckerModelCallAttempt.slot_id == slot_id) - .order_by(CheckerModelCallAttempt.attempt_number.desc()) - .limit(1) - ) - if lock: - query = query.with_for_update() - return await self._session.scalar(query.execution_options(populate_existing=True)) - - async def attempt(self, attempt_id: UUID, *, lock: bool = False): - """Load one server-owned attempt ID, optionally refreshing it under lock.""" - query = select(CheckerModelCallAttempt).where(CheckerModelCallAttempt.id == str(attempt_id)) - if lock: - query = query.with_for_update() - return await self._session.scalar(query.execution_options(populate_existing=True)) - - async def recovery( - self, - attempt_id: UUID, - checker_run_id: UUID, - worker_lease_id: UUID, - worker_lease_generation: int, - ): - """Resolve an attachment by its complete attempt, run, and worker lease identity.""" - return await self._session.scalar( - select(CheckerModelCallRecovery).where( - CheckerModelCallRecovery.attempt_id == str(attempt_id), - CheckerModelCallRecovery.checker_run_id == str(checker_run_id), - CheckerModelCallRecovery.worker_lease_id == str(worker_lease_id), - CheckerModelCallRecovery.worker_lease_generation == worker_lease_generation, - ) - ) - - async def add(self, row) -> None: - """Stage and flush a new owned row without committing the root transaction.""" - self._session.add(row) - await self._session.flush() - - async def flush(self) -> None: - """Flush pending state transitions while leaving commit to the caller.""" - await self._session.flush() - - -__all__ = ("ModelCallRepository", "require_model_call_transaction") diff --git a/backend/app/modules/checkers/model_calls/custody.py b/backend/app/modules/checkers/model_usage_custody.py similarity index 67% rename from backend/app/modules/checkers/model_calls/custody.py rename to backend/app/modules/checkers/model_usage_custody.py index 9fa99b7f4..b8c1d8d7f 100644 --- a/backend/app/modules/checkers/model_calls/custody.py +++ b/backend/app/modules/checkers/model_usage_custody.py @@ -1,6 +1,5 @@ """Current invocation and call-stable private evidence custody.""" -import json from uuid import UUID from sqlalchemy import select @@ -11,11 +10,14 @@ EvaluationTaskGuard, ExecutionLease, ) -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelCallCurrentExecutionPort, ModelCallExecutionFacts, ModelCallUnavailable, + ModelEvidenceCustodyPort, + ModelEvidenceSelector, ModelPaidSlotKey, + ModelRecoverySourcePort, ) from app.modules.checkers.api.output_custody import ( CheckerOutputReservation, @@ -31,15 +33,19 @@ stored_request, ) from app.modules.checkers.models import CheckerRun -from app.modules.checkers.model_calls.repository import ModelCallRepository -from app.modules.checkers.model_calls.grants import CheckerModelInvocationGrant class ModelCallCurrentExecution: """Check the exact locked project binding before any paid effect is reserved.""" - def __init__(self, session: AsyncSession, *, tasks: EvaluationTaskGuard): - self._session, self._tasks = session, tasks + def __init__( + self, + session: AsyncSession, + *, + tasks: EvaluationTaskGuard, + recovery: ModelRecoverySourcePort, + ): + self._session, self._tasks, self._recovery = session, tasks, recovery @staticmethod def _require_binding(request, slot: ModelPaidSlotKey) -> None: @@ -116,41 +122,27 @@ async def require_recovery_current( """Lock current TASK/Submission custody for a terminal source, without a lease.""" require_transaction(self._session) slot = ModelPaidSlotKey.model_validate(slot) - repo = ModelCallRepository(self._session) - slot_row = await repo.slot(slot) - parent = await repo.attempt(parent_attempt_id) - grant = await self._session.scalar( - select(CheckerModelInvocationGrant).where( - CheckerModelInvocationGrant.selected_attempt_id == str(parent_attempt_id) - ) - ) - if ( - slot_row is None - or parent is None - or parent.slot_id != slot_row.id - or grant is None - or grant.project_id != str(slot.project_id) - or grant.paid_slot != slot.model_dump(mode="json") - or grant.selected_request_sha256 != parent.request_sha256 - ): - raise ModelCallUnavailable("model_call_recovery_unavailable") - run = await self._session.get(CheckerRun, grant.checker_run_id) + try: + source = await self._recovery.recovery_owner(slot, parent_attempt_id) + except ModelCallUnavailable: + raise ModelCallUnavailable("model_call_recovery_unavailable") from None + run = await self._session.get(CheckerRun, str(source.checker_run_id)) if ( run is None - or run.task_id != slot_row.task_id + or run.task_id != str(source.task_id) or run.status != "infrastructure_failed" or run.failure_code is None or run.completed_at is None ): raise ModelCallUnavailable("model_call_recovery_unavailable") try: - execution = ModelCallExecutionFacts.model_validate_json( - json.dumps(grant.execution_facts) - ) request = stored_request(run) except (CheckerExecutionUnavailable, ValueError): raise ModelCallUnavailable("model_call_recovery_unavailable") from None - if execution.checker_run_id != UUID(run.id) or execution.task_id != UUID(slot_row.task_id): + if ( + source.execution.checker_run_id != UUID(run.id) + or source.execution.task_id != source.task_id + ): raise ModelCallUnavailable("model_call_recovery_unavailable") self._require_binding(request, slot) try: @@ -169,9 +161,10 @@ def __init__( *, current: ModelCallCurrentExecutionPort, tasks: CheckerOutputTaskCustodyPort, + model: ModelEvidenceCustodyPort, ): self._session, self._current = session, current - self._tasks = tasks + self._tasks, self._model = tasks, model async def resolve(self, selector: CheckerOutputSelector) -> CheckerOutputReservation: """Requests require current execution; late responses retain effect custody.""" @@ -181,37 +174,20 @@ async def resolve(self, selector: CheckerOutputSelector) -> CheckerOutputReserva await self._current.require_current(selector.execution, selector.paid_slot) else: await self._tasks.lock_model_evidence_scope(selector) - repo = ModelCallRepository(self._session) - await repo.lock_slot_scope(selector.paid_slot) - slot = await repo.slot(selector.paid_slot, lock=True) - if slot is None or ( - slot.id != str(selector.model_call_slot_id) or slot.task_id != str(selector.task_id) - ): - raise CheckerOutputUnavailable("checker_output_reservation_unavailable") - attempt = await repo.attempt(selector.model_call_attempt_id, lock=True) - if attempt is None or attempt.slot_id != slot.id: - raise CheckerOutputUnavailable("checker_output_reservation_unavailable") - profile = await repo.profile_exact( - selector.paid_slot.profile_id, - selector.paid_slot.profile_version, - selector.paid_slot.profile_digest, - ) - if profile is None or ( - profile.project_id != str(selector.paid_slot.project_id) - or profile.registry_entry_digest != selector.paid_slot.registry_entry_digest - ): - raise CheckerOutputUnavailable("checker_output_profile_unavailable") - if selector.slot_key == "model_request": - if attempt.request_sha256 != selector.content_sha256: - raise CheckerOutputUnavailable("checker_output_request_conflict") - maximum = profile.maximum_request_bytes - else: - if attempt.state not in {"dispatching", "uncertain", "completed", "known_failed"}: - raise CheckerOutputUnavailable("checker_output_effect_unavailable") - if attempt.response_content_sha256 not in {None, selector.content_sha256}: - raise CheckerOutputUnavailable("checker_output_response_conflict") - maximum = profile.maximum_response_bytes + try: + reservation = await self._model.resolve_evidence( + ModelEvidenceSelector( + paid_slot=selector.paid_slot, + task_id=selector.task_id, + model_call_slot_id=selector.model_call_slot_id, + model_call_attempt_id=selector.model_call_attempt_id, + role=selector.slot_key, + content_sha256=selector.content_sha256, + ) + ) + except ModelCallUnavailable: + raise CheckerOutputUnavailable("checker_output_reservation_unavailable") from None return CheckerOutputReservation( selector=selector, - slot=CheckerOutputSlot(key=selector.slot_key, maximum_bytes=maximum), + slot=CheckerOutputSlot(key=selector.slot_key, maximum_bytes=reservation.maximum_bytes), ) diff --git a/backend/app/modules/checkers/router.py b/backend/app/modules/checkers/router.py index aed3dca4a..1bc1a56b6 100644 --- a/backend/app/modules/checkers/router.py +++ b/backend/app/modules/checkers/router.py @@ -57,7 +57,7 @@ PostSubmitDeliveryRequest, PostSubmitDeliveryResult, ) -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelRecoveryChildRequest, PreparedModelCallAttempt, ProjectModelBudgetConfiguration, diff --git a/backend/app/modules/model_usage/__init__.py b/backend/app/modules/model_usage/__init__.py new file mode 100644 index 000000000..c11c91761 --- /dev/null +++ b/backend/app/modules/model_usage/__init__.py @@ -0,0 +1 @@ +"""Shared durable model-use ownership for setup and checker workloads.""" diff --git a/backend/app/modules/checkers/api/model_calls.py b/backend/app/modules/model_usage/api.py similarity index 61% rename from backend/app/modules/checkers/api/model_calls.py rename to backend/app/modules/model_usage/api.py index f6092bb4c..347a74c0e 100644 --- a/backend/app/modules/checkers/api/model_calls.py +++ b/backend/app/modules/model_usage/api.py @@ -130,12 +130,10 @@ def nonzero_and_canonical(self) -> Self: class ProjectModelProfileSpec(ModelCallValue): - """Operator-owned provider profile bound to one project and registry entry.""" + """Operator-owned provider and pricing profile shared by model-use owners.""" project_id: UUID profile_version: StrictInt = Field(ge=1) - registry_entry_id: UUID - registry_entry_digest: Sha256 provider: ProviderIdentifier api: ProviderIdentifier model_snapshot: ProviderIdentifier @@ -145,38 +143,85 @@ class ProjectModelProfileSpec(ModelCallValue): maximum_response_bytes: StrictInt = Field(ge=1, le=1_048_576) maximum_input_tokens: StrictInt = Field(ge=1, le=10_000_000) maximum_output_tokens: StrictInt = Field(ge=1, le=10_000_000) + maximum_calls: StrictInt = Field(ge=1, le=100) + maximum_total_request_bytes: StrictInt = Field(ge=1, le=104_857_600) + maximum_total_response_bytes: StrictInt = Field(ge=1, le=104_857_600) + maximum_total_input_tokens: StrictInt = Field(ge=1, le=100_000_000) + maximum_total_output_tokens: StrictInt = Field(ge=1, le=100_000_000) + allowed_tools: tuple[Identifier, ...] = () + maximum_tool_calls: StrictInt = Field(ge=0, le=1_000) + maximum_tool_output_bytes: StrictInt = Field(ge=0, le=104_857_600) deadline_ms: StrictInt = Field(ge=100, le=3_600_000) - call_slots: Literal[1] = 1 pricing: ModelPricing - _decode_public_uuids = field_validator("project_id", "registry_entry_id", mode="before")( - _public_uuid - ) + _decode_public_uuid = field_validator("project_id", mode="before")(_public_uuid) + + @field_validator("allowed_tools", mode="before") + @classmethod + def decode_public_tools(cls, value): + """Decode the JSON array representation into the frozen canonical tuple.""" + if isinstance(value, list): + return tuple(value) + return value @model_validator(mode="after") - def canonical_ids(self) -> Self: - _require_uuid7(self.registry_entry_id, "external checker registry entry ID") - if (self.provider, self.api) != ("openai_responses", "responses.v1"): - raise ValueError("model provider and API are unsupported") + def coherent_bounds(self) -> Self: if self.maximum_response_bytes < 6 * self.maximum_output_bytes + 4096: raise ValueError("model response evidence cannot preserve the maximum output") + if len(set(self.allowed_tools)) != len(self.allowed_tools): + raise ValueError("model profile tools must be unique") + if tuple(sorted(self.allowed_tools)) != self.allowed_tools: + raise ValueError("model profile tools must be canonical") + if not self.allowed_tools and ( + self.maximum_tool_calls != 0 or self.maximum_tool_output_bytes != 0 + ): + raise ValueError("tool-free model profiles cannot reserve tool capacity") + if self.allowed_tools and ( + self.maximum_tool_calls == 0 or self.maximum_tool_output_bytes == 0 + ): + raise ValueError("tool-enabled model profiles require finite tool bounds") + bounded_totals = ( + (self.maximum_request_bytes, self.maximum_total_request_bytes), + (self.maximum_response_bytes, self.maximum_total_response_bytes), + (self.maximum_input_tokens, self.maximum_total_input_tokens), + (self.maximum_output_tokens, self.maximum_total_output_tokens), + ) + if any(per_call > total for per_call, total in bounded_totals): + raise ValueError("model run totals cannot be smaller than one call") + if any(total > per_call * self.maximum_calls for per_call, total in bounded_totals): + raise ValueError("model run totals exceed the finite call envelope") return self @property def maximum_reservation_nanodollars(self) -> int: - """Reserve the maximum rate for each bounded input/output token class.""" + """Return the maximum settled plus outstanding amount for one whole run.""" input_rate = max( self.pricing.uncached_input_nanodollars_per_token, self.pricing.cached_input_nanodollars_per_token, ) value = ( - self.maximum_input_tokens * input_rate - + self.maximum_output_tokens * self.pricing.output_nanodollars_per_token + self.maximum_total_input_tokens * input_rate + + self.maximum_total_output_tokens * self.pricing.output_nanodollars_per_token ) if value <= 0 or value > 9_000_000_000_000_000_000: raise ValueError("model profile reservation is outside the supported range") return value + @property + def maximum_dispatch_reservation_nanodollars(self) -> int: + """Return the conservative reservation for one actual provider dispatch.""" + input_rate = max( + self.pricing.uncached_input_nanodollars_per_token, + self.pricing.cached_input_nanodollars_per_token, + ) + value = ( + self.maximum_input_tokens * input_rate + + self.maximum_output_tokens * self.pricing.output_nanodollars_per_token + ) + if value <= 0 or value > self.maximum_reservation_nanodollars: + raise ValueError("model dispatch reservation is outside the run envelope") + return value + class ModelProfileProvisioningFacts(ModelCallValue): """Complete immutable profile publication facts consumed by Operator authority.""" @@ -215,10 +260,17 @@ def selector(self) -> ModelProfileSelector: class ProjectModelBudgetConfigurationSpec(ModelCallValue): - """One immutable lifetime-ceiling adjustment; versions never reset counters.""" + """One explicit authorization/reconciliation and metered-ceiling adjustment. + + Externally reconciled prior spend excludes usage retained by this ledger. Later + immutable versions may only increase that external fact; account counters remain + lifetime values and are never folded into or reset by this field. + """ project_id: UUID configuration_version: StrictInt = Field(ge=1) + authorized_total_nanodollars: Nanodollars + externally_reconciled_prior_spend_nanodollars: Nanodollars lifetime_limit_nanodollars: Nanodollars _decode_public_uuid = field_validator("project_id", mode="before")(_public_uuid) @@ -227,6 +279,13 @@ class ProjectModelBudgetConfigurationSpec(ModelCallValue): def positive_limit(self) -> Self: if self.lifetime_limit_nanodollars <= 0: raise ValueError("model budget limit must be positive") + if self.authorized_total_nanodollars <= 0: + raise ValueError("model authorization ceiling must be positive") + if ( + self.externally_reconciled_prior_spend_nanodollars + self.lifetime_limit_nanodollars + > self.authorized_total_nanodollars + ): + raise ValueError("model metered allowance exceeds the reconciled authorization") return self @@ -273,7 +332,7 @@ class ModelPaidSlotKey(ModelCallValue): profile_id: UUID profile_version: StrictInt = Field(ge=1) profile_digest: Sha256 - slot_index: Literal[0] = 0 + slot_index: StrictInt = Field(default=0, ge=0, le=99) _decode_public_uuids = field_validator( "project_id", "assignment_id", "submission_id", "profile_id", mode="before" @@ -288,6 +347,236 @@ def canonical_profile(self) -> Self: def digest(self) -> str: return canonical_json_hash(self.model_dump(mode="json")) + @property + def envelope_digest(self) -> str: + """Commit the stable whole-run subject independently of a call index.""" + return canonical_json_hash(self.model_dump(mode="json", exclude={"slot_index"})) + + +class SetupModelUsageSlotKey(ModelCallValue): + """Exact setup-owned provider dispatch identity allocated before I/O.""" + + compilation_attempt_id: UUID + project_id: UUID + guide_id: UUID + source_snapshot_id: UUID + setup_run_id: UUID + setup_generation: StrictInt = Field(ge=1) + runtime_configuration_hash: Sha256 + profile_id: UUID + profile_version: StrictInt = Field(ge=1) + profile_digest: Sha256 + phase: Literal["draft", "review", "correction"] + call_index: StrictInt = Field(ge=0, le=99) + + _decode_public_uuids = field_validator( + "compilation_attempt_id", + "project_id", + "guide_id", + "source_snapshot_id", + "setup_run_id", + "profile_id", + mode="before", + )(_public_uuid) + + @model_validator(mode="after") + def canonical_records(self) -> Self: + _require_uuid7(self.compilation_attempt_id, "guide compilation attempt ID") + _require_uuid7(self.setup_run_id, "project setup run ID") + _require_uuid7(self.profile_id, "model profile ID") + return self + + @property + def digest(self) -> str: + """Commit the complete setup subject without provider request bytes.""" + return canonical_json_hash(self.model_dump(mode="json")) + + @property + def envelope_digest(self) -> str: + """Commit the setup attempt run independently of phase and call index.""" + return canonical_json_hash(self.model_dump(mode="json", exclude={"phase", "call_index"})) + + +class PreparedSetupModelUsageEffect(ModelCallValue): + """Prepared setup effect allocated under exact attempt custody before I/O.""" + + envelope_id: UUID + slot_id: UUID + slot_key: SetupModelUsageSlotKey + effect_id: UUID + attempt_number: StrictInt = Field(ge=1) + internal_call_request_id: UUID + request_sha256: Sha256 + state: Literal["prepared"] = "prepared" + replayed: bool + + +class SetupModelEffectAuthorityFacts(ModelCallValue): + """Exact setup-owned provider effect used by narrow observation authority.""" + + project_id: UUID + slot_id: UUID + slot_key: SetupModelUsageSlotKey + effect_id: UUID + internal_call_request_id: UUID + + +class SetupModelDispatchReservation(ModelCallValue): + """One setup provider dispatch reserved against shared project and run ceilings.""" + + envelope_id: UUID + slot_id: UUID + slot_key: SetupModelUsageSlotKey + effect_id: UUID + attempt_number: StrictInt = Field(ge=1) + internal_call_request_id: UUID + effect: SetupModelEffectAuthorityFacts + request_sha256: Sha256 + request_evidence: ModelCallEvidence + budget_configuration_id: UUID + budget_configuration_version: StrictInt = Field(ge=1) + budget_configuration_digest: Sha256 + reserved_nanodollars: Nanodollars + state: Literal["dispatching"] = "dispatching" + replayed: bool + + @model_validator(mode="after") + def exact_effect(self) -> Self: + if ( + self.effect.project_id != self.slot_key.project_id + or self.effect.slot_id != self.slot_id + or self.effect.slot_key != self.slot_key + or self.effect.effect_id != self.effect_id + or self.effect.internal_call_request_id != self.internal_call_request_id + ): + raise ValueError("setup dispatch effect identity mismatch") + return self + + +class ObservedSetupModelUsageEffect(ModelCallValue): + """Durable ambiguous provider effect that keeps its full reservation.""" + + effect: SetupModelEffectAuthorityFacts + uncertainty_digest: Sha256 + provider_response_id: ProviderResponseIdentifier | None = None + state: Literal["uncertain"] = "uncertain" + replayed: bool + + +class SetupModelUsageAuthorityPort(Protocol): + """PROJECTS-owned authorization for exact setup model-use mutations.""" + + async def authorize_setup_prepare( + self, slot: SetupModelUsageSlotKey, request_sha256: str + ) -> ModelActionEvidence: + """Authorize one preallocated setup call slot before owner locks.""" + ... + + async def authorize_setup_dispatch( + self, + prepared: PreparedSetupModelUsageEffect, + request_evidence: ModelCallEvidence, + ) -> ModelActionEvidence: + """Authorize one verified setup provider dispatch before owner locks.""" + ... + + async def authorize_setup_observe( + self, + effect: SetupModelEffectAuthorityFacts, + provider_response_id: str | None, + observation_digest: str, + ) -> ModelActionEvidence: + """Authorize retention of one ambiguous provider observation.""" + ... + + async def authorize_setup_complete( + self, + effect: SetupModelEffectAuthorityFacts, + response_evidence: ModelCallEvidence | None, + terminal_digest: str, + ) -> ModelActionEvidence: + """Authorize exact terminal accounting or authenticated no-effect release.""" + ... + + +class SetupModelUsageEvidencePort(Protocol): + """Verify private ART request evidence for an exact setup effect.""" + + async def require_setup_request( + self, + prepared: PreparedSetupModelUsageEffect, + evidence: ModelCallEvidence, + ) -> None: + """Require sealed bytes owned by the exact setup effect and request hash.""" + ... + + async def require_setup_response(self, effect_id: UUID, evidence: ModelCallEvidence) -> None: + """Require sealed response bytes owned by the exact setup effect.""" + ... + + +class SetupModelUsageOwnerPort(Protocol): + """Require the exact current PROJECTS compilation attempt before MODEL locks.""" + + async def require_setup_current(self, slot: SetupModelUsageSlotKey) -> None: + """Lock and validate the complete setup attempt ancestry.""" + ... + + +class ModelEvidenceSelector(ModelCallValue): + """Owner-neutral selector for one checker request or response evidence slot.""" + + paid_slot: ModelPaidSlotKey + task_id: UUID + model_call_slot_id: UUID + model_call_attempt_id: UUID + role: Literal["model_request", "model_response"] + content_sha256: Sha256 + + +class ModelEvidenceReservation(ModelCallValue): + """Bound returned by the shared ledger after exact effect validation.""" + + maximum_bytes: StrictInt = Field(ge=1, le=1_048_576) + + +class ModelEvidenceCustodyPort(Protocol): + """Resolve shared ledger custody after the owner has taken its own locks.""" + + async def resolve_evidence(self, selector: ModelEvidenceSelector) -> ModelEvidenceReservation: + """Lock envelope, slot and effect and return the exact role bound.""" + ... + + +class ModelRecoverySourcePort(Protocol): + """Read retained recovery lineage without granting fresh owner authority.""" + + async def recovery_owner( + self, slot: ModelPaidSlotKey, parent_attempt_id: UUID + ) -> ModelRecoveryOwnerFacts: + """Resolve an exact terminal effect to its originating checker run.""" + ... + + async def recovery_candidates( + self, source_run_ids: tuple[UUID, ...] + ) -> dict[UUID, tuple[ModelRecoveryCandidate, ...]]: + """Project retryable terminal effects for the requested retained runs.""" + ... + + +class ModelInvocationGrantAuthorityPort(Protocol): + """Issue one bounded callback capability without authorizing provider spend.""" + + async def authorize_grant( + self, + execution: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + external_request_digest: str, + token_hash: str, + ) -> ModelActionEvidence: + """Authorize the exact invocation, request binding and opaque token hash.""" + ... + class ModelCallExecutionFacts(ModelCallValue): """Current run/lease facts used only for authority and currentness checks.""" @@ -477,6 +766,29 @@ class KnownNoEffectProof(ModelCallValue): observed_at: AwareDatetime +class CompletedSetupModelUsageEffect(ModelCallValue): + """Exact retained setup response and once-settled project cost.""" + + effect: SetupModelEffectAuthorityFacts + provider_response_id: ProviderResponseIdentifier + request_evidence: ModelCallEvidence + response_evidence: ModelCallEvidence + usage: ModelCallUsage + settled_nanodollars: Nanodollars + completed_at: AwareDatetime + state: Literal["completed"] = "completed" + replayed: bool + + +class KnownNoEffectSetupModelUsageEffect(ModelCallValue): + """Authenticated zero-effect terminal setup call with released reservation.""" + + effect: SetupModelEffectAuthorityFacts + proof: KnownNoEffectProof + state: Literal["known_no_effect"] = "known_no_effect" + replayed: bool + + def model_retry_terminal_digest( terminal: KnownFailedModelCall | KnownNoEffectProof, ) -> str: @@ -506,6 +818,25 @@ class ModelActionEvidence(ModelCallValue): facts_digest: Sha256 +class ModelRecoveryOwnerFacts(ModelCallValue): + """Retained checker lineage selected from an exact paid effect and grant.""" + + checker_run_id: UUID + task_id: UUID + execution: ModelCallExecutionFacts + request_sha256: Sha256 + + +class ModelInvocationGrantFacts(ModelCallValue): + """Immutable owner facts selected privately by the proxy, never image input.""" + + token_hash: str = Field(repr=False) + external_request_digest: Sha256 + execution: ModelCallExecutionFacts + slot: ModelPaidSlotKey + registry_entry_id: UUID + + class ModelProfileReaderPort(Protocol): """Read one immutable profile by complete identity.""" @@ -514,19 +845,15 @@ async def read_exact( selector: ModelProfileSelector, *, project_id: UUID, - registry_entry_id: UUID, - registry_entry_digest: str, ) -> ProjectModelProfile: - """Resolve the complete selector only for its exact project and registry digest.""" + """Resolve the complete selector only for its exact project.""" ... class ModelCallAdministrationPort(Protocol): """Own exact public profile, budget and recovery-child mutations.""" - async def provision_profile( - self, facts: ModelProfileProvisioningFacts - ) -> ProjectModelProfile: + async def provision_profile(self, facts: ModelProfileProvisioningFacts) -> ProjectModelProfile: """Authorize and retain immutable profile facts with exact operation replay.""" ... @@ -552,9 +879,7 @@ async def prepare_recovery_child( class ModelCallCurrentExecutionPort(Protocol): """Require one current authorized checker execution without granting model effects.""" - async def require_current( - self, facts: ModelCallExecutionFacts, slot: ModelPaidSlotKey - ) -> None: + async def require_current(self, facts: ModelCallExecutionFacts, slot: ModelPaidSlotKey) -> None: """Require current TASK and checker lease custody before attaching a paid slot.""" ... @@ -582,15 +907,11 @@ async def require_response(self, attempt_id: UUID, evidence: ModelCallEvidence) class ModelCallAuthorityPort(Protocol): """Action-specific authority; implementations must reject phase substitution.""" - async def authorize_profile( - self, facts: ModelProfileProvisioningFacts - ) -> ModelActionEvidence: + async def authorize_profile(self, facts: ModelProfileProvisioningFacts) -> ModelActionEvidence: """Bind profile publication authority to the project and complete immutable facts.""" ... - async def authorize_budget( - self, facts: ModelBudgetProvisioningFacts - ) -> ModelActionEvidence: + async def authorize_budget(self, facts: ModelBudgetProvisioningFacts) -> ModelActionEvidence: """Bind budget publication authority to the exact project and configuration.""" ... @@ -678,9 +999,11 @@ def model_action_digest(action: str, facts: object) -> str: __all__ = ( + "CompletedSetupModelUsageEffect", "CompletedModelCall", "AttachedModelCallAttempt", "KnownNoEffectProof", + "KnownNoEffectSetupModelUsageEffect", "KnownFailedModelCall", "ModelActionEvidence", "ModelBudgetProvisioningFacts", @@ -698,18 +1021,33 @@ def model_action_digest(action: str, facts: object) -> str: "ModelCallRecoveryAttachment", "ModelDispatchReservation", "ModelGenerationSettings", + "ModelEvidenceCustodyPort", + "ModelEvidenceReservation", + "ModelEvidenceSelector", + "ModelInvocationGrantAuthorityPort", + "ModelInvocationGrantFacts", "ModelPaidSlotKey", "ModelPricing", "ModelProfileProvisioningFacts", "ModelRecoveryCandidate", "ModelRecoveryChildRequest", + "ModelRecoveryOwnerFacts", + "ModelRecoverySourcePort", "ModelProfileReaderPort", "ModelProfileSelector", + "ObservedSetupModelUsageEffect", "PreparedModelCallAttempt", "ProjectModelBudgetConfiguration", "ProjectModelBudgetConfigurationSpec", "ProjectModelProfile", "ProjectModelProfileSpec", + "PreparedSetupModelUsageEffect", + "SetupModelDispatchReservation", + "SetupModelEffectAuthorityFacts", + "SetupModelUsageAuthorityPort", + "SetupModelUsageEvidencePort", + "SetupModelUsageOwnerPort", + "SetupModelUsageSlotKey", "model_usage_cost", "model_action_digest", "model_retry_terminal_digest", diff --git a/backend/app/modules/checkers/model_calls/grants.py b/backend/app/modules/model_usage/grants.py similarity index 88% rename from backend/app/modules/checkers/model_calls/grants.py rename to backend/app/modules/model_usage/grants.py index 2dc0510ad..98a73307a 100644 --- a/backend/app/modules/checkers/model_calls/grants.py +++ b/backend/app/modules/model_usage/grants.py @@ -11,7 +11,7 @@ from app.core.identifiers import new_record_id from app.interfaces.external_checker_execution import ExternalCheckerModelGrant -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelCallCurrentExecutionPort, ModelCallExecutionFacts, ModelCallEffectAuthorityFacts, @@ -19,12 +19,12 @@ ModelPaidSlotKey, model_action_digest, ) -from app.modules.checkers.api.model_grants import ( +from app.modules.model_usage.api import ( ModelInvocationGrantAuthorityPort, ModelInvocationGrantFacts, ) -from app.modules.checkers.model_calls.repository import require_model_call_transaction -from app.modules.checkers.model_calls.models import CheckerModelInvocationGrant +from app.modules.model_usage.repository import require_model_call_transaction +from app.modules.model_usage.models import CheckerModelInvocationGrant def model_grant_token_hash(token: str) -> str: @@ -123,7 +123,11 @@ async def resolve(self, token: str, request_digest: str) -> ModelInvocationGrant ): raise ModelCallUnavailable("model_grant_unavailable") return ModelInvocationGrantFacts( - token_hash, request_digest, execution, slot, UUID(row.registry_entry_id) + token_hash=token_hash, + external_request_digest=request_digest, + execution=execution, + slot=slot, + registry_entry_id=UUID(row.registry_entry_id), ) async def consume_prepared( @@ -134,18 +138,22 @@ async def consume_prepared( prepared_attempt_id: UUID, ) -> None: """Consume after ledger preparation in the same AUTH/TASK/slot transaction.""" - from app.modules.checkers.model_calls.repository import ModelCallRepository + from app.modules.model_usage.repository import ModelCallRepository require_model_call_transaction(self._session) if re.fullmatch(r"sha256:[0-9a-f]{64}", request_sha256) is None: raise ModelCallUnavailable("model_grant_unavailable") repo = ModelCallRepository(self._session) + await repo.lock_envelope_scope(facts.slot) + if await repo.envelope(facts.slot, lock=True) is None: + raise ModelCallUnavailable("model_grant_unavailable") + await repo.lock_slot_scope(facts.slot) slot = await repo.slot(facts.slot, lock=True) attempt = await repo.attempt(prepared_attempt_id, lock=True) if ( slot is None or attempt is None - or (attempt.slot_id != slot.id or attempt.request_sha256 != request_sha256) + or (attempt.checker_slot_id != slot.id or attempt.request_sha256 != request_sha256) ): raise ModelCallUnavailable("model_grant_unavailable") row = await self._session.scalar( @@ -180,7 +188,7 @@ async def completed_effect( execution: ModelCallExecutionFacts, ) -> ModelCallEffectAuthorityFacts: """Select exact consumed effect facts; the ledger must freshly authorize their read.""" - from app.modules.checkers.model_calls.models import CheckerModelCallAttempt + from app.modules.model_usage.models import ModelUsageEffect require_model_call_transaction(self._session) row = await self._session.scalar( @@ -192,14 +200,14 @@ async def completed_effect( ) if row is None or row.execution_facts != execution.model_dump(mode="json"): raise ModelCallUnavailable("model_grant_completion_unavailable") - attempt = await self._session.get(CheckerModelCallAttempt, row.selected_attempt_id) + attempt = await self._session.get(ModelUsageEffect, row.selected_attempt_id) if attempt is None or attempt.request_sha256 != row.selected_request_sha256: raise ModelCallUnavailable("model_grant_completion_unavailable") slot = ModelPaidSlotKey.model_validate_json(json.dumps(row.paid_slot)) return ModelCallEffectAuthorityFacts( project_id=execution.project_id, task_id=execution.task_id, - slot_id=UUID(attempt.slot_id), + slot_id=UUID(attempt.checker_slot_id), slot_key=slot, attempt_id=UUID(attempt.id), internal_call_request_id=UUID(attempt.internal_call_request_id), diff --git a/backend/app/modules/checkers/model_calls/models.py b/backend/app/modules/model_usage/models.py similarity index 55% rename from backend/app/modules/checkers/model_calls/models.py rename to backend/app/modules/model_usage/models.py index d64601eb0..a7a4db317 100644 --- a/backend/app/modules/checkers/model_calls/models.py +++ b/backend/app/modules/model_usage/models.py @@ -27,35 +27,31 @@ _SHA = "^sha256:[0-9a-f]{64}$" -class CheckerModelProfile(Base): +class ModelUsageProfile(Base): """One immutable Operator-authorized provider/pricing profile.""" - __tablename__ = "checker_model_profiles" + __tablename__ = "model_usage_profiles" __table_args__ = ( CheckConstraint(_UUID7, name="id_uuid7"), - UniqueConstraint("project_id", "profile_version", name="checker_model_profiles_version"), + UniqueConstraint("project_id", "profile_version", name="model_usage_profiles_version"), UniqueConstraint( "id", "profile_version", "profile_digest", - name="checker_model_profiles_identity", + name="model_usage_profiles_identity", ), - UniqueConstraint("profile_digest", name="checker_model_profiles_digest"), - UniqueConstraint("operation_id", name="checker_model_profiles_operation"), - UniqueConstraint("authorization_evidence_id", name="checker_model_profiles_authority"), + UniqueConstraint("profile_digest", name="model_usage_profiles_digest"), + UniqueConstraint("operation_id", name="model_usage_profiles_operation"), + UniqueConstraint("authorization_evidence_id", name="model_usage_profiles_authority"), CheckConstraint( - f"profile_digest ~ '{_SHA}' and registry_entry_digest ~ '{_SHA}' and " - f"pricing_policy_digest ~ '{_SHA}'", + f"profile_digest ~ '{_SHA}' and pricing_policy_digest ~ '{_SHA}'", name="digests", ), CheckConstraint( - "profile_version > 0 and pricing_policy_version > 0 and call_slots = 1", + "profile_version > 0 and pricing_policy_version > 0 and " + "maximum_calls between 1 and 100", name="versions", ), - CheckConstraint( - "provider = 'openai_responses' and api = 'responses.v1'", - name="provider_api", - ), CheckConstraint( "maximum_request_bytes between 1 and 1048576 and " "maximum_output_bytes between 1 and 1048576 and " @@ -63,9 +59,27 @@ class CheckerModelProfile(Base): "maximum_response_bytes >= 6 * maximum_output_bytes + 4096 and " "maximum_input_tokens between 1 and 10000000 and " "maximum_output_tokens between 1 and 10000000 and " - "deadline_ms between 100 and 3600000", + "deadline_ms between 100 and 3600000 and " + "maximum_total_request_bytes between maximum_request_bytes and 104857600 and " + "maximum_total_response_bytes between maximum_response_bytes and 104857600 and " + "maximum_total_input_tokens between maximum_input_tokens and 100000000 and " + "maximum_total_output_tokens between maximum_output_tokens and 100000000 and " + "maximum_total_request_bytes <= maximum_request_bytes * maximum_calls and " + "maximum_total_response_bytes <= maximum_response_bytes * maximum_calls and " + "maximum_total_input_tokens <= maximum_input_tokens * maximum_calls and " + "maximum_total_output_tokens <= maximum_output_tokens * maximum_calls and " + "maximum_tool_calls between 0 and 1000 and " + "maximum_tool_output_bytes between 0 and 104857600", name="bounds", ), + CheckConstraint( + "jsonb_typeof(allowed_tools) = 'array' and " + "((jsonb_array_length(allowed_tools) = 0 and maximum_tool_calls = 0 and " + "maximum_tool_output_bytes = 0) or " + "(jsonb_array_length(allowed_tools) > 0 and maximum_tool_calls > 0 and " + "maximum_tool_output_bytes > 0))", + name="tools", + ), CheckConstraint( "uncached_input_rate >= 0 and cached_input_rate >= 0 and " "output_rate >= 0 and " @@ -80,12 +94,6 @@ class CheckerModelProfile(Base): ) profile_version: Mapped[int] = mapped_column(Integer, nullable=False) profile_digest: Mapped[str] = mapped_column(String(71), nullable=False) - registry_entry_id: Mapped[str] = mapped_column( - Uuid(as_uuid=False), - ForeignKey("external_checker_registry_entries.id", ondelete="RESTRICT"), - nullable=False, - ) - registry_entry_digest: Mapped[str] = mapped_column(String(71), nullable=False) provider: Mapped[str] = mapped_column(String(160), nullable=False) api: Mapped[str] = mapped_column(String(160), nullable=False) model_snapshot: Mapped[str] = mapped_column(String(160), nullable=False) @@ -95,8 +103,15 @@ class CheckerModelProfile(Base): maximum_response_bytes: Mapped[int] = mapped_column(Integer, nullable=False) maximum_input_tokens: Mapped[int] = mapped_column(Integer, nullable=False) maximum_output_tokens: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_calls: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_total_request_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_total_response_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_total_input_tokens: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_total_output_tokens: Mapped[int] = mapped_column(Integer, nullable=False) + allowed_tools: Mapped[list[str]] = mapped_column(JSONB(), nullable=False) + maximum_tool_calls: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_tool_output_bytes: Mapped[int] = mapped_column(Integer, nullable=False) deadline_ms: Mapped[int] = mapped_column(Integer, nullable=False) - call_slots: Mapped[int] = mapped_column(Integer, nullable=False, default=1) pricing_policy_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) pricing_policy_version: Mapped[int] = mapped_column(Integer, nullable=False) pricing_policy_digest: Mapped[str] = mapped_column(String(71), nullable=False) @@ -115,16 +130,16 @@ class CheckerModelProfile(Base): ) -class CheckerModelBudgetAccount(Base): +class ModelUsageBudgetAccount(Base): """One project lifetime account; configuration versions never reset counters.""" - __tablename__ = "checker_model_budget_accounts" + __tablename__ = "model_usage_budget_accounts" __table_args__ = ( ForeignKeyConstraint( ["current_configuration_id", "project_id"], [ - "checker_model_budget_configurations.id", - "checker_model_budget_configurations.project_id", + "model_usage_budget_configurations.id", + "model_usage_budget_configurations.project_id", ], ondelete="RESTRICT", name="configuration_identity", @@ -147,39 +162,43 @@ class CheckerModelBudgetAccount(Base): lifetime_settled_nanodollars: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) -class CheckerModelBudgetConfiguration(Base): +class ModelUsageBudgetConfiguration(Base): """Immutable Operator-authorized adjustment of one project's lifetime ceiling.""" - __tablename__ = "checker_model_budget_configurations" + __tablename__ = "model_usage_budget_configurations" __table_args__ = ( CheckConstraint(_UUID7, name="id_uuid7"), UniqueConstraint( "project_id", "configuration_version", - name="checker_model_budget_configurations_version", + name="model_usage_budget_configurations_version", ), UniqueConstraint( "id", "project_id", "configuration_version", "configuration_digest", - name="checker_model_budget_configurations_identity", + name="model_usage_budget_configurations_identity", ), UniqueConstraint( "id", "configuration_version", "configuration_digest", - name="checker_model_budget_configurations_attempt_identity", + name="model_usage_budget_configurations_attempt_identity", ), - UniqueConstraint("configuration_digest", name="checker_model_budget_configurations_digest"), - UniqueConstraint("operation_id", name="checker_model_budget_configurations_operation"), + UniqueConstraint("configuration_digest", name="model_usage_budget_configurations_digest"), + UniqueConstraint("operation_id", name="model_usage_budget_configurations_operation"), UniqueConstraint( "authorization_evidence_id", - name="checker_model_budget_configurations_authority", + name="model_usage_budget_configurations_authority", ), CheckConstraint(f"configuration_digest ~ '{_SHA}'", name="digest"), CheckConstraint( - "configuration_version > 0 and lifetime_limit_nanodollars > 0", + "configuration_version > 0 and authorized_total_nanodollars > 0 and " + "externally_reconciled_prior_spend_nanodollars >= 0 and " + "lifetime_limit_nanodollars > 0 and " + "externally_reconciled_prior_spend_nanodollars + " + "lifetime_limit_nanodollars <= authorized_total_nanodollars", name="positive_values", ), ) @@ -190,6 +209,10 @@ class CheckerModelBudgetConfiguration(Base): ) configuration_version: Mapped[int] = mapped_column(Integer, nullable=False) configuration_digest: Mapped[str] = mapped_column(String(71), nullable=False) + authorized_total_nanodollars: Mapped[int] = mapped_column(BigInteger, nullable=False) + externally_reconciled_prior_spend_nanodollars: Mapped[int] = mapped_column( + BigInteger, nullable=False + ) lifetime_limit_nanodollars: Mapped[int] = mapped_column(BigInteger, nullable=False) operation_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) actor_profile_id: Mapped[str] = mapped_column( @@ -203,10 +226,81 @@ class CheckerModelBudgetConfiguration(Base): ) -class CheckerModelCallSlot(Base): +class CheckerModelUsageEnvelope(Base): + """Submission-stable whole-run ceiling shared by checker call slots and retries.""" + + __tablename__ = "checker_model_usage_envelopes" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint( + "project_id", + "assignment_id", + "submission_id", + "submission_version", + "requirement_id", + "registry_entry_digest", + "configuration_sha256", + "profile_id", + "profile_version", + "profile_digest", + name="checker_model_usage_envelopes_stable_identity", + ), + ForeignKeyConstraint( + ["profile_id", "profile_version", "profile_digest"], + [ + "model_usage_profiles.id", + "model_usage_profiles.profile_version", + "model_usage_profiles.profile_digest", + ], + name="profile_identity", + ondelete="RESTRICT", + ), + CheckConstraint( + f"registry_entry_digest ~ '{_SHA}' and configuration_sha256 ~ '{_SHA}' and " + f"profile_digest ~ '{_SHA}'", + name="digests", + ), + CheckConstraint( + "submission_version > 0 and profile_version > 0", + name="versions", + ), + CheckConstraint( + "requirement_id ~ '^[a-z][a-z0-9_.-]{0,99}$'", + name="requirement_id", + ), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + project_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False + ) + task_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("workstream_tasks.id", ondelete="RESTRICT"), + nullable=False, + ) + assignment_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("task_assignments.id", ondelete="RESTRICT"), nullable=False + ) + submission_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("submissions.id", ondelete="RESTRICT"), nullable=False + ) + submission_version: Mapped[int] = mapped_column(Integer, nullable=False) + requirement_id: Mapped[str] = mapped_column(String(100), nullable=False) + registry_entry_digest: Mapped[str] = mapped_column(String(71), nullable=False) + configuration_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + profile_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + profile_version: Mapped[int] = mapped_column(Integer, nullable=False) + profile_digest: Mapped[str] = mapped_column(String(71), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + + +class CheckerModelUsageSlot(Base): """Submission-stable paid effect identity independent of run generation and lease.""" - __tablename__ = "checker_model_call_slots" + __tablename__ = "checker_model_usage_slots" __table_args__ = ( CheckConstraint(_UUID7, name="id_uuid7"), UniqueConstraint( @@ -221,14 +315,14 @@ class CheckerModelCallSlot(Base): "profile_version", "profile_digest", "slot_index", - name="checker_model_call_slots_stable_identity", + name="checker_model_usage_slots_stable_identity", ), ForeignKeyConstraint( ["profile_id", "profile_version", "profile_digest"], [ - "checker_model_profiles.id", - "checker_model_profiles.profile_version", - "checker_model_profiles.profile_digest", + "model_usage_profiles.id", + "model_usage_profiles.profile_version", + "model_usage_profiles.profile_digest", ], name="profile_identity", ondelete="RESTRICT", @@ -239,7 +333,7 @@ class CheckerModelCallSlot(Base): name="digests", ), CheckConstraint( - "submission_version > 0 and profile_version > 0 and slot_index = 0", + "submission_version > 0 and profile_version > 0 and slot_index between 0 and 99", name="versions", ), CheckConstraint( @@ -249,6 +343,11 @@ class CheckerModelCallSlot(Base): ) id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + envelope_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("checker_model_usage_envelopes.id", ondelete="RESTRICT"), + nullable=False, + ) project_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False ) @@ -277,19 +376,183 @@ class CheckerModelCallSlot(Base): ) -class CheckerModelCallAttempt(Base): +class SetupModelUsageEnvelope(Base): + """Whole-run ceiling strongly owned by one exact setup compilation attempt.""" + + __tablename__ = "setup_model_usage_envelopes" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint( + "compilation_attempt_id", + "runtime_configuration_hash", + "profile_id", + "profile_version", + "profile_digest", + name="setup_model_usage_envelopes_stable_identity", + ), + ForeignKeyConstraint( + [ + "compilation_attempt_id", + "project_id", + "guide_id", + "source_snapshot_id", + "setup_run_id", + "setup_generation", + "runtime_configuration_hash", + ], + [ + "project_guide_compilation_attempts.id", + "project_guide_compilation_attempts.project_id", + "project_guide_compilation_attempts.guide_id", + "project_guide_compilation_attempts.source_snapshot_id", + "project_guide_compilation_attempts.setup_run_id", + "project_guide_compilation_attempts.setup_generation", + "project_guide_compilation_attempts.runtime_configuration_hash", + ], + name="setup_attempt_identity", + ondelete="RESTRICT", + ), + ForeignKeyConstraint( + ["profile_id", "profile_version", "profile_digest"], + [ + "model_usage_profiles.id", + "model_usage_profiles.profile_version", + "model_usage_profiles.profile_digest", + ], + name="profile_identity", + ondelete="RESTRICT", + ), + CheckConstraint( + f"runtime_configuration_hash ~ '{_SHA}' and profile_digest ~ '{_SHA}'", + name="digests", + ), + CheckConstraint( + "setup_generation > 0 and profile_version > 0", + name="versions", + ), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + compilation_attempt_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + project_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + guide_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + source_snapshot_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + setup_run_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + setup_generation: Mapped[int] = mapped_column(BigInteger, nullable=False) + runtime_configuration_hash: Mapped[str] = mapped_column(String(71), nullable=False) + profile_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + profile_version: Mapped[int] = mapped_column(Integer, nullable=False) + profile_digest: Mapped[str] = mapped_column(String(71), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + + +class SetupModelUsageSlot(Base): + """One provider dispatch slot strongly owned by an exact setup attempt.""" + + __tablename__ = "setup_model_usage_slots" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint( + "compilation_attempt_id", + "runtime_configuration_hash", + "profile_id", + "profile_version", + "profile_digest", + "phase", + "call_index", + name="setup_model_usage_slots_stable_identity", + ), + ForeignKeyConstraint( + [ + "compilation_attempt_id", + "project_id", + "guide_id", + "source_snapshot_id", + "setup_run_id", + "setup_generation", + "runtime_configuration_hash", + ], + [ + "project_guide_compilation_attempts.id", + "project_guide_compilation_attempts.project_id", + "project_guide_compilation_attempts.guide_id", + "project_guide_compilation_attempts.source_snapshot_id", + "project_guide_compilation_attempts.setup_run_id", + "project_guide_compilation_attempts.setup_generation", + "project_guide_compilation_attempts.runtime_configuration_hash", + ], + name="setup_attempt_identity", + ondelete="RESTRICT", + ), + ForeignKeyConstraint( + ["profile_id", "profile_version", "profile_digest"], + [ + "model_usage_profiles.id", + "model_usage_profiles.profile_version", + "model_usage_profiles.profile_digest", + ], + name="profile_identity", + ondelete="RESTRICT", + ), + CheckConstraint( + f"runtime_configuration_hash ~ '{_SHA}' and profile_digest ~ '{_SHA}' and " + f"request_sha256 ~ '{_SHA}'", + name="digests", + ), + CheckConstraint( + "setup_generation > 0 and profile_version > 0 and call_index between 0 and 99", + name="versions", + ), + CheckConstraint( + "phase in ('draft','review','correction')", + name="phase", + ), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + envelope_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("setup_model_usage_envelopes.id", ondelete="RESTRICT"), + nullable=False, + ) + compilation_attempt_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + project_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + guide_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + source_snapshot_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + setup_run_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + setup_generation: Mapped[int] = mapped_column(BigInteger, nullable=False) + runtime_configuration_hash: Mapped[str] = mapped_column(String(71), nullable=False) + profile_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + profile_version: Mapped[int] = mapped_column(Integer, nullable=False) + profile_digest: Mapped[str] = mapped_column(String(71), nullable=False) + phase: Mapped[str] = mapped_column(String(32), nullable=False) + call_index: Mapped[int] = mapped_column(Integer, nullable=False) + request_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + + +class ModelUsageEffect(Base): """One server-numbered provider effect attempt beneath a stable paid slot.""" - __tablename__ = "checker_model_call_attempts" + __tablename__ = "model_usage_effects" __table_args__ = ( CheckConstraint(_UUID7, name="id_uuid7"), - UniqueConstraint("slot_id", "attempt_number", name="checker_model_call_attempts_number"), UniqueConstraint( - "internal_call_request_id", name="checker_model_call_attempts_internal_request" + "checker_slot_id", "attempt_number", name="model_usage_effects_checker_number" + ), + UniqueConstraint( + "setup_slot_id", "attempt_number", name="model_usage_effects_setup_number" + ), + UniqueConstraint( + "internal_call_request_id", name="model_usage_effects_internal_request" ), UniqueConstraint( "prepared_authorization_evidence_id", - name="checker_model_call_attempts_prepare_authority", + name="model_usage_effects_prepare_authority", ), ForeignKeyConstraint( [ @@ -298,9 +561,9 @@ class CheckerModelCallAttempt(Base): "budget_configuration_digest", ], [ - "checker_model_budget_configurations.id", - "checker_model_budget_configurations.configuration_version", - "checker_model_budget_configurations.configuration_digest", + "model_usage_budget_configurations.id", + "model_usage_budget_configurations.configuration_version", + "model_usage_budget_configurations.configuration_digest", ], ondelete="RESTRICT", name="budget_identity", @@ -320,19 +583,26 @@ class CheckerModelCallAttempt(Base): "and output_tokens >= 0 and reasoning_tokens between 0 and output_tokens)", name="usage", ), + CheckConstraint( + "num_nonnulls(checker_slot_id, setup_slot_id) = 1", + name="exact_owner", + ), ) id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) - slot_id: Mapped[str] = mapped_column( + checker_slot_id: Mapped[str | None] = mapped_column( Uuid(as_uuid=False), - ForeignKey("checker_model_call_slots.id", ondelete="RESTRICT"), - nullable=False, + ForeignKey("checker_model_usage_slots.id", ondelete="RESTRICT"), + ) + setup_slot_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("setup_model_usage_slots.id", ondelete="RESTRICT"), ) attempt_number: Mapped[int] = mapped_column(Integer, nullable=False) internal_call_request_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) parent_attempt_id: Mapped[str | None] = mapped_column( Uuid(as_uuid=False), - ForeignKey("checker_model_call_attempts.id", ondelete="RESTRICT"), + ForeignKey("model_usage_effects.id", ondelete="RESTRICT"), ) request_sha256: Mapped[str] = mapped_column(String(71), nullable=False) state: Mapped[str] = mapped_column(String(32), nullable=False) @@ -403,10 +673,10 @@ class CheckerModelCallAttempt(Base): terminal_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) -class CheckerModelCallRecovery(Base): +class CheckerModelUsageRecovery(Base): """One fresh current-run recovery attachment to an existing paid effect.""" - __tablename__ = "checker_model_call_recoveries" + __tablename__ = "checker_model_usage_recoveries" __table_args__ = ( CheckConstraint(_UUID7, name="id_uuid7"), UniqueConstraint( @@ -414,10 +684,10 @@ class CheckerModelCallRecovery(Base): "checker_run_id", "worker_lease_id", "worker_lease_generation", - name="checker_model_call_recoveries_attachment", + name="checker_model_usage_recoveries_attachment", ), UniqueConstraint( - "authorization_evidence_id", name="checker_model_call_recoveries_authority" + "authorization_evidence_id", name="checker_model_usage_recoveries_authority" ), CheckConstraint("worker_lease_generation > 0", name="lease_generation"), ) @@ -425,7 +695,7 @@ class CheckerModelCallRecovery(Base): id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) attempt_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), - ForeignKey("checker_model_call_attempts.id", ondelete="RESTRICT"), + ForeignKey("model_usage_effects.id", ondelete="RESTRICT"), nullable=False, ) checker_run_id: Mapped[str] = mapped_column( @@ -491,17 +761,20 @@ class CheckerModelInvocationGrant(Base): selected_request_sha256: Mapped[str | None] = mapped_column(String(71)) selected_attempt_id: Mapped[str | None] = mapped_column( Uuid(as_uuid=False), - ForeignKey("checker_model_call_attempts.id", ondelete="RESTRICT"), + ForeignKey("model_usage_effects.id", ondelete="RESTRICT"), ) consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) __all__ = ( - "CheckerModelBudgetAccount", - "CheckerModelBudgetConfiguration", - "CheckerModelCallAttempt", - "CheckerModelCallRecovery", - "CheckerModelCallSlot", + "ModelUsageBudgetAccount", + "ModelUsageBudgetConfiguration", + "ModelUsageEffect", + "CheckerModelUsageRecovery", + "CheckerModelUsageEnvelope", + "CheckerModelUsageSlot", + "SetupModelUsageEnvelope", + "SetupModelUsageSlot", "CheckerModelInvocationGrant", - "CheckerModelProfile", + "ModelUsageProfile", ) diff --git a/backend/app/modules/model_usage/owner_ports.py b/backend/app/modules/model_usage/owner_ports.py new file mode 100644 index 000000000..dbfdd56b7 --- /dev/null +++ b/backend/app/modules/model_usage/owner_ports.py @@ -0,0 +1,163 @@ +"""Narrow shared-ledger views injected into checker owner adapters.""" + +from __future__ import annotations + +import json +from uuid import UUID + +from sqlalchemy import exists, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from app.modules.model_usage.api import ( + ModelCallExecutionFacts, + ModelCallUnavailable, + ModelEvidenceReservation, + ModelEvidenceSelector, + ModelPaidSlotKey, + ModelRecoveryCandidate, + ModelRecoveryOwnerFacts, +) +from app.modules.model_usage.models import ( + CheckerModelInvocationGrant, + CheckerModelUsageSlot, + ModelUsageEffect, +) +from app.modules.model_usage.repository import ModelCallRepository +from app.modules.model_usage.service import ( + _slot_key, + _stored_retry_terminal_digest, + _uuid, +) + + +class ModelEvidenceCustody: + """Lock and validate exact shared effect custody after owner locks.""" + + def __init__(self, session: AsyncSession) -> None: + self._repo = ModelCallRepository(session) + + async def resolve_evidence(self, selector: ModelEvidenceSelector) -> ModelEvidenceReservation: + selector = ModelEvidenceSelector.model_validate(selector) + await self._repo.lock_envelope_scope(selector.paid_slot) + if await self._repo.envelope(selector.paid_slot, lock=True) is None: + raise ModelCallUnavailable("model_evidence_unavailable") + await self._repo.lock_slot_scope(selector.paid_slot) + slot = await self._repo.slot(selector.paid_slot, lock=True) + if slot is None or ( + slot.id != str(selector.model_call_slot_id) or slot.task_id != str(selector.task_id) + ): + raise ModelCallUnavailable("model_evidence_unavailable") + effect = await self._repo.attempt(selector.model_call_attempt_id, lock=True) + if effect is None or effect.checker_slot_id != slot.id: + raise ModelCallUnavailable("model_evidence_unavailable") + profile = await self._repo.profile_exact( + selector.paid_slot.profile_id, + selector.paid_slot.profile_version, + selector.paid_slot.profile_digest, + ) + if profile is None or profile.project_id != str(selector.paid_slot.project_id): + raise ModelCallUnavailable("model_evidence_profile_unavailable") + if selector.role == "model_request": + if effect.request_sha256 != selector.content_sha256: + raise ModelCallUnavailable("model_evidence_request_conflict") + maximum = profile.maximum_request_bytes + else: + if effect.state not in {"dispatching", "uncertain", "completed", "known_failed"}: + raise ModelCallUnavailable("model_evidence_effect_unavailable") + if effect.response_content_sha256 not in {None, selector.content_sha256}: + raise ModelCallUnavailable("model_evidence_response_conflict") + maximum = profile.maximum_response_bytes + return ModelEvidenceReservation(maximum_bytes=maximum) + + +class ModelRecoverySources: + """Project retained checker recovery lineage without owner authorization.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + self._repo = ModelCallRepository(session) + + async def recovery_owner( + self, slot: ModelPaidSlotKey, parent_attempt_id: UUID + ) -> ModelRecoveryOwnerFacts: + slot = ModelPaidSlotKey.model_validate(slot) + slot_row = await self._repo.slot(slot) + parent = await self._repo.attempt(parent_attempt_id) + grant = await self._session.scalar( + select(CheckerModelInvocationGrant).where( + CheckerModelInvocationGrant.selected_attempt_id == str(parent_attempt_id) + ) + ) + if ( + slot_row is None + or parent is None + or parent.checker_slot_id != slot_row.id + or grant is None + or grant.project_id != str(slot.project_id) + or grant.paid_slot != slot.model_dump(mode="json") + or grant.selected_request_sha256 != parent.request_sha256 + ): + raise ModelCallUnavailable("model_recovery_source_unavailable") + try: + execution = ModelCallExecutionFacts.model_validate_json( + json.dumps(grant.execution_facts) + ) + except ValueError as exc: + raise ModelCallUnavailable("model_recovery_source_unavailable") from exc + return ModelRecoveryOwnerFacts( + checker_run_id=UUID(grant.checker_run_id), + task_id=UUID(slot_row.task_id), + execution=execution, + request_sha256=parent.request_sha256, + ) + + async def recovery_candidates( + self, source_run_ids: tuple[UUID, ...] + ) -> dict[UUID, tuple[ModelRecoveryCandidate, ...]]: + if not source_run_ids: + return {} + child = aliased(ModelUsageEffect) + rows = ( + await self._session.execute( + select( + CheckerModelInvocationGrant.checker_run_id, + ModelUsageEffect, + CheckerModelUsageSlot, + CheckerModelInvocationGrant.registry_entry_id, + ) + .join( + ModelUsageEffect, + ModelUsageEffect.id == CheckerModelInvocationGrant.selected_attempt_id, + ) + .join( + CheckerModelUsageSlot, + CheckerModelUsageSlot.id == ModelUsageEffect.checker_slot_id, + ) + .where( + CheckerModelInvocationGrant.checker_run_id.in_( + tuple(str(value) for value in source_run_ids) + ), + ModelUsageEffect.state.in_(("known_failed", "known_no_effect")), + ~exists(select(child.id).where(child.parent_attempt_id == ModelUsageEffect.id)), + ) + .order_by(CheckerModelInvocationGrant.checker_run_id, ModelUsageEffect.id) + ) + ).all() + projected: dict[UUID, dict[UUID, ModelRecoveryCandidate]] = {} + for run_id, attempt, slot_row, registry_entry_id in rows: + digest = _stored_retry_terminal_digest(attempt) + if digest is None: + continue + candidate = ModelRecoveryCandidate( + parent_attempt_id=_uuid(attempt.id), + slot=_slot_key(slot_row), + registry_entry_id=_uuid(registry_entry_id), + terminal_digest=digest, + request_sha256=attempt.request_sha256, + ) + projected.setdefault(_uuid(run_id), {})[candidate.parent_attempt_id] = candidate + return {run_id: tuple(candidates.values()) for run_id, candidates in projected.items()} + + +__all__ = ("ModelEvidenceCustody", "ModelRecoverySources") diff --git a/backend/app/modules/checkers/model_calls/profiles.py b/backend/app/modules/model_usage/profiles.py similarity index 74% rename from backend/app/modules/checkers/model_calls/profiles.py rename to backend/app/modules/model_usage/profiles.py index bee8c05dd..c1cfbe3fe 100644 --- a/backend/app/modules/checkers/model_calls/profiles.py +++ b/backend/app/modules/model_usage/profiles.py @@ -7,7 +7,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelCallUnavailable, ModelGenerationSettings, ModelPricing, @@ -15,10 +15,10 @@ ProjectModelProfile, ProjectModelProfileSpec, ) -from app.modules.checkers.model_calls.models import CheckerModelProfile +from app.modules.model_usage.models import ModelUsageProfile -def stored_model_profile(row: CheckerModelProfile) -> ProjectModelProfile: +def stored_model_profile(row: ModelUsageProfile) -> ProjectModelProfile: """Decode one immutable row through the strict public profile contract.""" pricing = ModelPricing( pricing_policy_id=UUID(row.pricing_policy_id), @@ -31,8 +31,6 @@ def stored_model_profile(row: CheckerModelProfile) -> ProjectModelProfile: spec = ProjectModelProfileSpec( project_id=UUID(row.project_id), profile_version=row.profile_version, - registry_entry_id=UUID(row.registry_entry_id), - registry_entry_digest=row.registry_entry_digest, provider=row.provider, api=row.api, model_snapshot=row.model_snapshot, @@ -42,8 +40,15 @@ def stored_model_profile(row: CheckerModelProfile) -> ProjectModelProfile: maximum_response_bytes=row.maximum_response_bytes, maximum_input_tokens=row.maximum_input_tokens, maximum_output_tokens=row.maximum_output_tokens, + maximum_calls=row.maximum_calls, + maximum_total_request_bytes=row.maximum_total_request_bytes, + maximum_total_response_bytes=row.maximum_total_response_bytes, + maximum_total_input_tokens=row.maximum_total_input_tokens, + maximum_total_output_tokens=row.maximum_total_output_tokens, + allowed_tools=tuple(row.allowed_tools), + maximum_tool_calls=row.maximum_tool_calls, + maximum_tool_output_bytes=row.maximum_tool_output_bytes, deadline_ms=row.deadline_ms, - call_slots=row.call_slots, pricing=pricing, ) return ProjectModelProfile( @@ -79,23 +84,19 @@ async def read_exact( selector: ModelProfileSelector, *, project_id: UUID, - registry_entry_id: UUID, - registry_entry_digest: str, ) -> ProjectModelProfile: - """Require the complete selector plus exact project and registry custody.""" + """Require the complete selector plus exact project custody.""" self._require_root_transaction() try: selector = ModelProfileSelector.model_validate(selector) except ValueError as exc: raise ModelCallUnavailable("model_profile_unavailable") from exc row = await self._session.scalar( - select(CheckerModelProfile).where( - CheckerModelProfile.id == str(selector.profile_id), - CheckerModelProfile.profile_version == selector.profile_version, - CheckerModelProfile.profile_digest == selector.profile_digest, - CheckerModelProfile.project_id == str(project_id), - CheckerModelProfile.registry_entry_id == str(registry_entry_id), - CheckerModelProfile.registry_entry_digest == registry_entry_digest, + select(ModelUsageProfile).where( + ModelUsageProfile.id == str(selector.profile_id), + ModelUsageProfile.profile_version == selector.profile_version, + ModelUsageProfile.profile_digest == selector.profile_digest, + ModelUsageProfile.project_id == str(project_id), ) ) if row is None: diff --git a/backend/app/modules/model_usage/repository.py b/backend/app/modules/model_usage/repository.py new file mode 100644 index 000000000..77d52cf60 --- /dev/null +++ b/backend/app/modules/model_usage/repository.py @@ -0,0 +1,383 @@ +"""Flush-only persistence and lock order for project model-call ownership.""" + +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import case, func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.model_usage.api import ( + ModelPaidSlotKey, + ModelCallUnavailable, + SetupModelUsageSlotKey, +) +from app.modules.model_usage.models import ( + ModelUsageBudgetAccount, + ModelUsageBudgetConfiguration, + ModelUsageEffect, + CheckerModelUsageEnvelope, + CheckerModelUsageRecovery, + CheckerModelUsageSlot, + SetupModelUsageEnvelope, + SetupModelUsageSlot, + ModelUsageProfile, +) + + +@dataclass(frozen=True) +class ModelUsageEnvelopeTotals: + """Current committed effects beneath one locked whole-run envelope.""" + + dispatches: int + pending_dispatches: int + reserved_nanodollars: int + settled_nanodollars: int + request_bytes: int + response_bytes: int + input_tokens: int + output_tokens: int + + +def require_model_call_transaction(session: AsyncSession) -> None: + """Require one caller-owned root transaction and reject nested ownership.""" + transaction = session.sync_session.get_transaction() + if transaction is None or not transaction.is_active or session.in_nested_transaction(): + raise ModelCallUnavailable("model_call_transaction_unavailable") + + +class ModelCallRepository: + """Own model-call row locks without committing the caller's transaction.""" + + def __init__(self, session: AsyncSession) -> None: + """Bind all reads, writes, and locks to the caller-owned session.""" + self._session = session + + async def now(self): + """Read the authoritative database clock inside the current transaction.""" + return await self._session.scalar(select(func.clock_timestamp())) + + async def advisory(self, key: str) -> None: + """Take a transaction-scoped advisory lock for one canonical owner key.""" + await self._session.execute( + text("select pg_advisory_xact_lock(hashtextextended(:key, 0))"), + {"key": key}, + ) + + async def lock_profile_scopes( + self, operation_id: UUID, project_id: UUID, profile_version: int + ) -> None: + """Serialize profile idempotency and project version allocation in order.""" + for key in ( + f"checker-model-profile-operation:{operation_id}", + f"checker-model-profile:{project_id}", + ): + await self.advisory(key) + + async def profile_by_operation(self, operation_id: UUID): + """Return the immutable profile previously written for an operation.""" + return await self._session.scalar( + select(ModelUsageProfile).where(ModelUsageProfile.operation_id == str(operation_id)) + ) + + async def profile_by_version(self, project_id: UUID, profile_version: int): + """Return the profile occupying one project-scoped version, if present.""" + return await self._session.scalar( + select(ModelUsageProfile).where( + ModelUsageProfile.project_id == str(project_id), + ModelUsageProfile.profile_version == profile_version, + ) + ) + + async def latest_profile(self, project_id: UUID): + """Return the highest immutable profile version currently stored for a project.""" + return await self._session.scalar( + select(ModelUsageProfile) + .where(ModelUsageProfile.project_id == str(project_id)) + .order_by(ModelUsageProfile.profile_version.desc()) + .limit(1) + ) + + async def profile_exact(self, profile_id: UUID, profile_version: int, profile_digest: str): + """Resolve a profile only when its ID, version, and digest all match.""" + return await self._session.scalar( + select(ModelUsageProfile).where( + ModelUsageProfile.id == str(profile_id), + ModelUsageProfile.profile_version == profile_version, + ModelUsageProfile.profile_digest == profile_digest, + ) + ) + + async def lock_budget_scope(self, project_id: UUID) -> None: + """Serialize lifetime budget mutations for one project.""" + await self.advisory(f"checker-model-budget:{project_id}") + + async def budget_account(self, project_id: UUID, *, lock: bool = False): + """Load the project lifetime account, optionally taking its row lock.""" + query = select(ModelUsageBudgetAccount).where( + ModelUsageBudgetAccount.project_id == str(project_id) + ) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def budget_configuration_by_operation(self, operation_id: UUID): + """Return the immutable budget configuration written for an operation.""" + return await self._session.scalar( + select(ModelUsageBudgetConfiguration).where( + ModelUsageBudgetConfiguration.operation_id == str(operation_id) + ) + ) + + async def budget_configuration_by_version(self, project_id: UUID, version: int): + """Return one exact project budget version, if it exists.""" + return await self._session.scalar( + select(ModelUsageBudgetConfiguration).where( + ModelUsageBudgetConfiguration.project_id == str(project_id), + ModelUsageBudgetConfiguration.configuration_version == version, + ) + ) + + async def current_budget_configuration(self, account: ModelUsageBudgetAccount): + """Refresh the configuration selected by the locked lifetime account.""" + return await self._session.get( + ModelUsageBudgetConfiguration, + account.current_configuration_id, + populate_existing=True, + ) + + @staticmethod + def _envelope_predicates(key: ModelPaidSlotKey): + """Build the complete stable whole-run identity predicate.""" + return ( + CheckerModelUsageEnvelope.project_id == str(key.project_id), + CheckerModelUsageEnvelope.assignment_id == str(key.assignment_id), + CheckerModelUsageEnvelope.submission_id == str(key.submission_id), + CheckerModelUsageEnvelope.submission_version == key.submission_version, + CheckerModelUsageEnvelope.requirement_id == key.requirement_id, + CheckerModelUsageEnvelope.registry_entry_digest == key.registry_entry_digest, + CheckerModelUsageEnvelope.configuration_sha256 == key.configuration_sha256, + CheckerModelUsageEnvelope.profile_id == str(key.profile_id), + CheckerModelUsageEnvelope.profile_version == key.profile_version, + CheckerModelUsageEnvelope.profile_digest == key.profile_digest, + ) + + async def lock_envelope_scope(self, key: ModelPaidSlotKey) -> None: + """Serialize all slots and effects under one stable checker run envelope.""" + await self.advisory("checker-model-usage-envelope:" + key.envelope_digest) + + async def envelope(self, key: ModelPaidSlotKey, *, lock: bool = False): + """Load one exact checker whole-run envelope.""" + query = select(CheckerModelUsageEnvelope).where(*self._envelope_predicates(key)) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + @staticmethod + def _slot_predicates(key: ModelPaidSlotKey): + """Build the complete paid-slot identity predicate without partial matching.""" + return ( + CheckerModelUsageSlot.project_id == str(key.project_id), + CheckerModelUsageSlot.assignment_id == str(key.assignment_id), + CheckerModelUsageSlot.submission_id == str(key.submission_id), + CheckerModelUsageSlot.submission_version == key.submission_version, + CheckerModelUsageSlot.requirement_id == key.requirement_id, + CheckerModelUsageSlot.registry_entry_digest == key.registry_entry_digest, + CheckerModelUsageSlot.configuration_sha256 == key.configuration_sha256, + CheckerModelUsageSlot.profile_id == str(key.profile_id), + CheckerModelUsageSlot.profile_version == key.profile_version, + CheckerModelUsageSlot.profile_digest == key.profile_digest, + CheckerModelUsageSlot.slot_index == key.slot_index, + ) + + async def lock_slot_scope(self, key: ModelPaidSlotKey) -> None: + """Serialize creation and state transitions for one exact paid slot.""" + await self.advisory("checker-model-call-slot:" + key.digest) + + async def slot(self, key: ModelPaidSlotKey, *, lock: bool = False): + """Load an exact paid slot and optionally lock it for mutation.""" + query = select(CheckerModelUsageSlot).where(*self._slot_predicates(key)) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def latest_attempt(self, slot_id: str, *, lock: bool = False): + """Load the greatest attempt number for a slot, optionally for update.""" + query = ( + select(ModelUsageEffect) + .where(ModelUsageEffect.checker_slot_id == slot_id) + .order_by(ModelUsageEffect.attempt_number.desc()) + .limit(1) + ) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def envelope_totals(self, envelope_id: str) -> ModelUsageEnvelopeTotals: + """Aggregate all effect-bearing checker attempts under a locked envelope.""" + row = ( + await self._session.execute( + select( + func.count(ModelUsageEffect.id).filter(ModelUsageEffect.state != "prepared"), + func.count(ModelUsageEffect.id).filter( + ModelUsageEffect.state.in_(("dispatching", "uncertain")) + ), + func.coalesce( + func.sum( + case( + ( + ModelUsageEffect.state.in_(("dispatching", "uncertain")), + ModelUsageEffect.reserved_nanodollars, + ), + else_=0, + ) + ), + 0, + ), + func.coalesce(func.sum(ModelUsageEffect.settled_nanodollars), 0), + func.coalesce(func.sum(ModelUsageEffect.request_byte_count), 0), + func.coalesce(func.sum(ModelUsageEffect.response_byte_count), 0), + func.coalesce(func.sum(ModelUsageEffect.input_tokens), 0), + func.coalesce(func.sum(ModelUsageEffect.output_tokens), 0), + ) + .join( + CheckerModelUsageSlot, + CheckerModelUsageSlot.id == ModelUsageEffect.checker_slot_id, + ) + .where(CheckerModelUsageSlot.envelope_id == envelope_id) + ) + ).one() + return ModelUsageEnvelopeTotals(*(int(value) for value in row)) + + @staticmethod + def _setup_envelope_predicates(key: SetupModelUsageSlotKey): + return ( + SetupModelUsageEnvelope.compilation_attempt_id == str(key.compilation_attempt_id), + SetupModelUsageEnvelope.project_id == str(key.project_id), + SetupModelUsageEnvelope.guide_id == str(key.guide_id), + SetupModelUsageEnvelope.source_snapshot_id == str(key.source_snapshot_id), + SetupModelUsageEnvelope.setup_run_id == str(key.setup_run_id), + SetupModelUsageEnvelope.setup_generation == key.setup_generation, + SetupModelUsageEnvelope.runtime_configuration_hash == key.runtime_configuration_hash, + SetupModelUsageEnvelope.profile_id == str(key.profile_id), + SetupModelUsageEnvelope.profile_version == key.profile_version, + SetupModelUsageEnvelope.profile_digest == key.profile_digest, + ) + + async def lock_setup_envelope_scope(self, key: SetupModelUsageSlotKey) -> None: + """Serialize all phases and calls under one exact setup attempt envelope.""" + await self.advisory("setup-model-usage-envelope:" + key.envelope_digest) + + async def setup_envelope(self, key: SetupModelUsageSlotKey, *, lock: bool = False): + query = select(SetupModelUsageEnvelope).where(*self._setup_envelope_predicates(key)) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + @staticmethod + def _setup_slot_predicates(key: SetupModelUsageSlotKey): + return ( + SetupModelUsageSlot.compilation_attempt_id == str(key.compilation_attempt_id), + SetupModelUsageSlot.runtime_configuration_hash == key.runtime_configuration_hash, + SetupModelUsageSlot.profile_id == str(key.profile_id), + SetupModelUsageSlot.profile_version == key.profile_version, + SetupModelUsageSlot.profile_digest == key.profile_digest, + SetupModelUsageSlot.phase == key.phase, + SetupModelUsageSlot.call_index == key.call_index, + ) + + async def lock_setup_slot_scope(self, key: SetupModelUsageSlotKey) -> None: + await self.advisory("setup-model-usage-slot:" + key.digest) + + async def setup_slot(self, key: SetupModelUsageSlotKey, *, lock: bool = False): + query = select(SetupModelUsageSlot).where(*self._setup_slot_predicates(key)) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def latest_setup_effect(self, slot_id: str, *, lock: bool = False): + query = ( + select(ModelUsageEffect) + .where(ModelUsageEffect.setup_slot_id == slot_id) + .order_by(ModelUsageEffect.attempt_number.desc()) + .limit(1) + ) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def setup_envelope_totals(self, envelope_id: str) -> ModelUsageEnvelopeTotals: + row = ( + await self._session.execute( + select( + func.count(ModelUsageEffect.id).filter(ModelUsageEffect.state != "prepared"), + func.count(ModelUsageEffect.id).filter( + ModelUsageEffect.state.in_(("dispatching", "uncertain")) + ), + func.coalesce( + func.sum( + case( + ( + ModelUsageEffect.state.in_(("dispatching", "uncertain")), + ModelUsageEffect.reserved_nanodollars, + ), + else_=0, + ) + ), + 0, + ), + func.coalesce(func.sum(ModelUsageEffect.settled_nanodollars), 0), + func.coalesce(func.sum(ModelUsageEffect.request_byte_count), 0), + func.coalesce(func.sum(ModelUsageEffect.response_byte_count), 0), + func.coalesce(func.sum(ModelUsageEffect.input_tokens), 0), + func.coalesce(func.sum(ModelUsageEffect.output_tokens), 0), + ) + .join( + SetupModelUsageSlot, + SetupModelUsageSlot.id == ModelUsageEffect.setup_slot_id, + ) + .where(SetupModelUsageSlot.envelope_id == envelope_id) + ) + ).one() + return ModelUsageEnvelopeTotals(*(int(value) for value in row)) + + async def attempt(self, attempt_id: UUID, *, lock: bool = False): + """Load one server-owned attempt ID, optionally refreshing it under lock.""" + query = select(ModelUsageEffect).where(ModelUsageEffect.id == str(attempt_id)) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def recovery( + self, + attempt_id: UUID, + checker_run_id: UUID, + worker_lease_id: UUID, + worker_lease_generation: int, + ): + """Resolve an attachment by its complete attempt, run, and worker lease identity.""" + return await self._session.scalar( + select(CheckerModelUsageRecovery).where( + CheckerModelUsageRecovery.attempt_id == str(attempt_id), + CheckerModelUsageRecovery.checker_run_id == str(checker_run_id), + CheckerModelUsageRecovery.worker_lease_id == str(worker_lease_id), + CheckerModelUsageRecovery.worker_lease_generation == worker_lease_generation, + ) + ) + + async def add(self, row) -> None: + """Stage and flush a new owned row without committing the root transaction.""" + self._session.add(row) + await self._session.flush() + + async def flush(self) -> None: + """Flush pending state transitions while leaving commit to the caller.""" + await self._session.flush() + + +__all__ = ( + "ModelCallRepository", + "ModelUsageEnvelopeTotals", + "require_model_call_transaction", +) diff --git a/backend/app/modules/checkers/model_calls/service.py b/backend/app/modules/model_usage/service.py similarity index 85% rename from backend/app/modules/checkers/model_calls/service.py rename to backend/app/modules/model_usage/service.py index 55942760d..ff969d0dc 100644 --- a/backend/app/modules/checkers/model_calls/service.py +++ b/backend/app/modules/model_usage/service.py @@ -5,13 +5,11 @@ from uuid import UUID from pydantic import TypeAdapter -from sqlalchemy import exists, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import aliased from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( AttachedModelCallAttempt, CompletedModelCall, KnownFailedModelCall, @@ -33,7 +31,6 @@ ModelPaidSlotKey, ModelProfileProvisioningFacts, ModelProfileSelector, - ModelRecoveryCandidate, PreparedModelCallAttempt, ProviderResponseIdentifier, ProjectModelBudgetConfiguration, @@ -42,19 +39,20 @@ model_retry_terminal_digest, model_usage_cost, ) -from app.modules.checkers.model_calls.models import ( - CheckerModelBudgetAccount, - CheckerModelBudgetConfiguration, - CheckerModelCallAttempt, - CheckerModelCallRecovery, - CheckerModelCallSlot, - CheckerModelProfile, +from app.modules.model_usage.models import ( + ModelUsageBudgetAccount, + ModelUsageBudgetConfiguration, + ModelUsageEffect, + CheckerModelUsageRecovery, + CheckerModelUsageEnvelope, + CheckerModelUsageSlot, + ModelUsageProfile, ) -from app.modules.checkers.model_calls.repository import ( +from app.modules.model_usage.repository import ( ModelCallRepository, require_model_call_transaction, ) -from app.modules.checkers.model_calls.profiles import stored_model_profile +from app.modules.model_usage.profiles import stored_model_profile def _uuid(value: str) -> UUID: @@ -92,7 +90,7 @@ def _require_authority(action: str, facts: object, evidence: ModelActionEvidence raise ModelCallUnavailable("model_call_authority_unavailable") -def _slot_key(row: CheckerModelCallSlot) -> ModelPaidSlotKey: +def _slot_key(row: CheckerModelUsageSlot) -> ModelPaidSlotKey: """Project a stored slot through the complete immutable paid-slot identity.""" return ModelPaidSlotKey( project_id=_uuid(row.project_id), @@ -109,7 +107,7 @@ def _slot_key(row: CheckerModelCallSlot) -> ModelPaidSlotKey: ) -def _evidence(row: CheckerModelCallAttempt, prefix: str) -> ModelCallEvidence: +def _evidence(row: ModelUsageEffect, prefix: str) -> ModelCallEvidence: """Decode one complete request or response ART evidence set from an attempt.""" values = { "binding_id": getattr(row, f"{prefix}_binding_id"), @@ -132,8 +130,8 @@ def _evidence(row: CheckerModelCallAttempt, prefix: str) -> ModelCallEvidence: def _attempt( - slot: CheckerModelCallSlot, - row: CheckerModelCallAttempt, + slot: CheckerModelUsageSlot, + row: ModelUsageEffect, *, replayed: bool, ) -> AttachedModelCallAttempt: @@ -153,9 +151,7 @@ def _attempt( return AttachedModelCallAttempt.model_validate(values) -def _effect( - slot: CheckerModelCallSlot, row: CheckerModelCallAttempt -) -> ModelCallEffectAuthorityFacts: +def _effect(slot: CheckerModelUsageSlot, row: ModelUsageEffect) -> ModelCallEffectAuthorityFacts: """Build the authority facts identifying one durable provider effect.""" return ModelCallEffectAuthorityFacts( project_id=_uuid(slot.project_id), @@ -169,15 +165,15 @@ def _effect( def _require_effect( supplied: ModelCallEffectAuthorityFacts, - slot: CheckerModelCallSlot, - row: CheckerModelCallAttempt, + slot: CheckerModelUsageSlot, + row: ModelUsageEffect, ) -> None: """Reject supplied effect authority facts that differ from stored custody.""" if supplied != _effect(slot, row): raise ModelCallUnavailable("model_call_effect_unavailable") -def _usage(row: CheckerModelCallAttempt) -> ModelCallUsage | None: +def _usage(row: ModelUsageEffect) -> ModelCallUsage | None: """Return complete stored usage, distinguishing absent from partial counters.""" values = ( row.input_tokens, @@ -197,7 +193,7 @@ def _usage(row: CheckerModelCallAttempt) -> ModelCallUsage | None: ) -def _stored_retry_terminal_digest(row: CheckerModelCallAttempt) -> str | None: +def _stored_retry_terminal_digest(row: ModelUsageEffect) -> str | None: """Reconstruct a retry-eligible terminal digest only from complete stored facts.""" if row.state == "known_no_effect": if ( @@ -238,66 +234,6 @@ def _stored_retry_terminal_digest(row: CheckerModelCallAttempt) -> str | None: return None -async def model_recovery_candidates( - session: AsyncSession, source_run_ids: tuple[UUID, ...] -) -> dict[UUID, tuple[ModelRecoveryCandidate, ...]]: - """Project retry-read projection of exact retryable paid effects.""" - if not source_run_ids: - return {} - from app.modules.checkers.model_calls.grants import CheckerModelInvocationGrant - - child = aliased(CheckerModelCallAttempt) - rows = ( - await session.execute( - select( - CheckerModelInvocationGrant.checker_run_id, - CheckerModelCallAttempt, - CheckerModelCallSlot, - CheckerModelProfile.registry_entry_id, - ) - .join( - CheckerModelCallAttempt, - CheckerModelCallAttempt.id - == CheckerModelInvocationGrant.selected_attempt_id, - ) - .join(CheckerModelCallSlot, CheckerModelCallSlot.id == CheckerModelCallAttempt.slot_id) - .join(CheckerModelProfile, CheckerModelProfile.id == CheckerModelCallSlot.profile_id) - .where( - CheckerModelInvocationGrant.checker_run_id.in_( - tuple(str(value) for value in source_run_ids) - ), - CheckerModelCallAttempt.state.in_(("known_failed", "known_no_effect")), - ~exists( - select(child.id).where( - child.parent_attempt_id == CheckerModelCallAttempt.id - ) - ), - ) - .order_by( - CheckerModelInvocationGrant.checker_run_id, - CheckerModelCallAttempt.id, - ) - ) - ).all() - projected: dict[UUID, dict[UUID, ModelRecoveryCandidate]] = {} - for run_id, attempt, slot, registry_entry_id in rows: - digest = _stored_retry_terminal_digest(attempt) - if digest is None: - continue - candidate = ModelRecoveryCandidate( - parent_attempt_id=_uuid(attempt.id), - slot=_slot_key(slot), - registry_entry_id=_uuid(registry_entry_id), - terminal_digest=digest, - request_sha256=attempt.request_sha256, - ) - projected.setdefault(_uuid(run_id), {})[candidate.parent_attempt_id] = candidate - return { - run_id: tuple(candidates.values()) - for run_id, candidates in projected.items() - } - - class ModelCallService: """Flush-only state machine; the caller owns commit or rollback.""" @@ -327,11 +263,6 @@ async def provision_profile(self, facts: ModelProfileProvisioningFacts) -> Proje _ = facts.spec.maximum_reservation_nanodollars authority = await self._authority.authorize_profile(facts) _require_authority("profile", facts, authority) - if not await self._repo.is_exact_post_submit_registry_entry( - facts.spec.registry_entry_id, facts.spec.registry_entry_digest - ): - raise ModelCallConflict("model_profile_registry_conflict") - await self._repo.lock_profile_scopes( facts.operation_id, facts.spec.project_id, facts.spec.profile_version ) @@ -359,13 +290,11 @@ async def provision_profile(self, facts: ModelProfileProvisioningFacts) -> Proje spec = facts.spec pricing = spec.pricing - row = CheckerModelProfile( + row = ModelUsageProfile( id=str(new_record_id()), project_id=str(spec.project_id), profile_version=spec.profile_version, profile_digest=facts.profile_digest, - registry_entry_id=str(spec.registry_entry_id), - registry_entry_digest=spec.registry_entry_digest, provider=spec.provider, api=spec.api, model_snapshot=spec.model_snapshot, @@ -375,8 +304,15 @@ async def provision_profile(self, facts: ModelProfileProvisioningFacts) -> Proje maximum_response_bytes=spec.maximum_response_bytes, maximum_input_tokens=spec.maximum_input_tokens, maximum_output_tokens=spec.maximum_output_tokens, + maximum_calls=spec.maximum_calls, + maximum_total_request_bytes=spec.maximum_total_request_bytes, + maximum_total_response_bytes=spec.maximum_total_response_bytes, + maximum_total_input_tokens=spec.maximum_total_input_tokens, + maximum_total_output_tokens=spec.maximum_total_output_tokens, + allowed_tools=list(spec.allowed_tools), + maximum_tool_calls=spec.maximum_tool_calls, + maximum_tool_output_bytes=spec.maximum_tool_output_bytes, deadline_ms=spec.deadline_ms, - call_slots=spec.call_slots, pricing_policy_id=str(pricing.pricing_policy_id), pricing_policy_version=pricing.pricing_policy_version, pricing_policy_digest=pricing.pricing_policy_digest, @@ -424,6 +360,12 @@ async def provision_budget( expected_version = 1 if current is None else current.configuration_version + 1 if spec.configuration_version != expected_version: raise ModelCallConflict("model_budget_version_conflict") + if current is not None and ( + spec.authorized_total_nanodollars < current.authorized_total_nanodollars + or spec.externally_reconciled_prior_spend_nanodollars + < current.externally_reconciled_prior_spend_nanodollars + ): + raise ModelCallConflict("model_budget_reconciliation_conflict") if ( await self._repo.budget_configuration_by_version( spec.project_id, spec.configuration_version @@ -436,11 +378,15 @@ async def provision_budget( if spec.lifetime_limit_nanodollars < reserved + settled: raise ModelCallBudgetUnavailable("model_budget_below_lifetime_usage") - row = CheckerModelBudgetConfiguration( + row = ModelUsageBudgetConfiguration( id=str(new_record_id()), project_id=str(spec.project_id), configuration_version=spec.configuration_version, configuration_digest=facts.configuration_digest, + authorized_total_nanodollars=spec.authorized_total_nanodollars, + externally_reconciled_prior_spend_nanodollars=( + spec.externally_reconciled_prior_spend_nanodollars + ), lifetime_limit_nanodollars=spec.lifetime_limit_nanodollars, operation_id=str(facts.operation_id), actor_profile_id=str(facts.actor_profile_id), @@ -448,7 +394,7 @@ async def provision_budget( ) await self._repo.add(row) if account is None: - account = CheckerModelBudgetAccount( + account = ModelUsageBudgetAccount( project_id=str(spec.project_id), current_configuration_id=row.id, lifetime_reserved_nanodollars=0, @@ -462,7 +408,7 @@ async def provision_budget( @staticmethod def _budget_result( - row: CheckerModelBudgetConfiguration, account: CheckerModelBudgetAccount + row: ModelUsageBudgetConfiguration, account: ModelUsageBudgetAccount ) -> ProjectModelBudgetConfiguration: """Project an immutable budget version with its current lifetime counters.""" return ProjectModelBudgetConfiguration( @@ -472,6 +418,10 @@ def _budget_result( spec={ "project_id": _uuid(row.project_id), "configuration_version": row.configuration_version, + "authorized_total_nanodollars": row.authorized_total_nanodollars, + "externally_reconciled_prior_spend_nanodollars": ( + row.externally_reconciled_prior_spend_nanodollars + ), "lifetime_limit_nanodollars": row.lifetime_limit_nanodollars, }, configuration_digest=row.configuration_digest, @@ -486,8 +436,6 @@ async def read_exact( selector: ModelProfileSelector, *, project_id: UUID, - registry_entry_id: UUID, - registry_entry_digest: str, ) -> ProjectModelProfile: """Read an exact profile without treating a partial selector as authority.""" self._require_transaction() @@ -498,11 +446,7 @@ async def read_exact( if row is None: raise ModelCallUnavailable("model_profile_unavailable") result = stored_model_profile(row) - if ( - result.spec.project_id != project_id - or result.spec.registry_entry_id != registry_entry_id - or result.spec.registry_entry_digest != registry_entry_digest - ): + if result.spec.project_id != project_id: raise ModelCallUnavailable("model_profile_unavailable") return result @@ -520,7 +464,7 @@ def _require_execution_slot(execution: ModelCallExecutionFacts, slot: ModelPaidS async def _profile_for_slot( self, slot: ModelPaidSlotKey, registry_entry_id: UUID ) -> ProjectModelProfile: - """Resolve the exact stored profile and registry entry selected by a new slot.""" + """Resolve the exact stored profile selected by a checker-owned slot.""" return await self.read_exact( ModelProfileSelector( profile_id=slot.profile_id, @@ -528,8 +472,6 @@ async def _profile_for_slot( profile_digest=slot.profile_digest, ), project_id=slot.project_id, - registry_entry_id=registry_entry_id, - registry_entry_digest=slot.registry_entry_digest, ) async def _profile_for_stored_slot(self, slot: ModelPaidSlotKey) -> ProjectModelProfile: @@ -540,13 +482,30 @@ async def _profile_for_stored_slot(self, slot: ModelPaidSlotKey) -> ProjectModel if row is None: raise ModelCallUnavailable("model_profile_unavailable") result = stored_model_profile(row) - if ( - result.spec.project_id != slot.project_id - or result.spec.registry_entry_digest != slot.registry_entry_digest - ): + if result.spec.project_id != slot.project_id: raise ModelCallUnavailable("model_profile_unavailable") return result + async def _require_checker_envelope( + self, + execution: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + ) -> CheckerModelUsageEnvelope: + """Lock or create the immutable whole-run checker owner before slot custody.""" + await self._repo.lock_envelope_scope(slot) + row = await self._repo.envelope(slot, lock=True) + if row is None: + values = slot.model_dump(mode="json", exclude={"slot_index"}) + row = CheckerModelUsageEnvelope( + id=str(new_record_id()), + task_id=str(execution.task_id), + **values, + ) + await self._repo.add(row) + elif row.task_id != str(execution.task_id): + raise ModelCallConflict("model_call_envelope_conflict") + return row + async def prepare_attempt( self, execution: ModelCallExecutionFacts, @@ -574,11 +533,13 @@ async def prepare_attempt( await self._current.require_current(execution, slot) await self._profile_for_slot(slot, registry_entry_id) + envelope = await self._require_checker_envelope(execution, slot) await self._repo.lock_slot_scope(slot) slot_row = await self._repo.slot(slot, lock=True) if slot_row is None: - slot_row = CheckerModelCallSlot( + slot_row = CheckerModelUsageSlot( id=str(new_record_id()), + envelope_id=envelope.id, task_id=str(execution.task_id), **slot.model_dump(mode="json"), first_request_sha256=request_sha256, @@ -592,9 +553,9 @@ async def prepare_attempt( if attempt is not None: return _attempt(slot_row, attempt, replayed=True) - attempt = CheckerModelCallAttempt( + attempt = ModelUsageEffect( id=str(new_record_id()), - slot_id=slot_row.id, + checker_slot_id=slot_row.id, attempt_number=1, internal_call_request_id=str(new_record_id()), request_sha256=request_sha256, @@ -630,6 +591,13 @@ async def prepare_recovery_child( await self._current.require_recovery_current(slot, parent_attempt_id) await self._profile_for_slot(slot, registry_entry_id) + envelope = await self._repo.envelope(slot) + if envelope is None: + raise ModelCallUnavailable("model_call_parent_unavailable") + await self._repo.lock_envelope_scope(slot) + envelope = await self._repo.envelope(slot, lock=True) + if envelope is None: + raise ModelCallUnavailable("model_call_parent_unavailable") await self._repo.lock_slot_scope(slot) slot_row = await self._repo.slot(slot, lock=True) if slot_row is None: @@ -646,7 +614,7 @@ async def prepare_recovery_child( ) if ( parent is None - or parent.slot_id != slot_row.id + or parent.checker_slot_id != slot_row.id or not self._eligible_parent(parent, terminal_digest) ): raise ModelCallConflict("model_call_parent_not_retryable") @@ -662,9 +630,9 @@ async def prepare_recovery_child( ) raise ModelCallConflict("model_call_parent_superseded") - child = CheckerModelCallAttempt( + child = ModelUsageEffect( id=str(new_record_id()), - slot_id=slot_row.id, + checker_slot_id=slot_row.id, attempt_number=latest.attempt_number + 1, internal_call_request_id=str(new_record_id()), parent_attempt_id=str(parent_attempt_id), @@ -676,7 +644,7 @@ async def prepare_recovery_child( return PreparedModelCallAttempt.model_validate(_attempt(slot_row, child, replayed=False)) @staticmethod - def _eligible_parent(row: CheckerModelCallAttempt, terminal_digest: str) -> bool: + def _eligible_parent(row: ModelUsageEffect, terminal_digest: str) -> bool: """Accept a retry parent only when complete stored terminal facts match the digest.""" return _stored_retry_terminal_digest(row) == terminal_digest @@ -708,12 +676,16 @@ async def reserve_dispatch( ): raise ModelCallConflict("model_call_request_evidence_conflict") + await self._repo.lock_envelope_scope(attempt.slot_key) + envelope = await self._repo.envelope(attempt.slot_key, lock=True) + if envelope is None: + raise ModelCallUnavailable("model_call_envelope_unavailable") await self._repo.lock_slot_scope(attempt.slot_key) slot_row = await self._repo.slot(attempt.slot_key, lock=True) if slot_row is None or slot_row.id != str(attempt.slot_id): raise ModelCallUnavailable("model_call_attempt_unavailable") row = await self._repo.attempt(attempt.attempt_id, lock=True) - if row is None or row.slot_id != slot_row.id: + if row is None or row.checker_slot_id != slot_row.id: raise ModelCallUnavailable("model_call_attempt_unavailable") if row.state != "prepared": if _evidence(row, "request") != request_evidence: @@ -726,6 +698,26 @@ async def reserve_dispatch( ): raise ModelCallConflict("model_call_attempt_conflict") + totals = await self._repo.envelope_totals(envelope.id) + reservation = profile.spec.maximum_dispatch_reservation_nanodollars + if ( + totals.dispatches >= profile.spec.maximum_calls + or totals.reserved_nanodollars + totals.settled_nanodollars + reservation + > profile.spec.maximum_reservation_nanodollars + or totals.request_bytes + request_evidence.byte_count + > profile.spec.maximum_total_request_bytes + or totals.response_bytes + + (totals.pending_dispatches + 1) * profile.spec.maximum_response_bytes + > profile.spec.maximum_total_response_bytes + or totals.input_tokens + + (totals.pending_dispatches + 1) * profile.spec.maximum_input_tokens + > profile.spec.maximum_total_input_tokens + or totals.output_tokens + + (totals.pending_dispatches + 1) * profile.spec.maximum_output_tokens + > profile.spec.maximum_total_output_tokens + ): + raise ModelCallBudgetUnavailable("model_run_envelope_unavailable") + await self._repo.lock_budget_scope(attempt.slot_key.project_id) account = await self._repo.budget_account(attempt.slot_key.project_id, lock=True) if account is None: @@ -733,7 +725,6 @@ async def reserve_dispatch( budget = await self._repo.current_budget_configuration(account) if budget is None: raise ModelCallBudgetUnavailable("model_budget_unavailable") - reservation = profile.spec.maximum_reservation_nanodollars if ( account.lifetime_reserved_nanodollars + account.lifetime_settled_nanodollars @@ -755,18 +746,16 @@ async def reserve_dispatch( return self._dispatch_result(row, slot=slot_row, replayed=False) @staticmethod - def _set_evidence( - row: CheckerModelCallAttempt, prefix: str, evidence: ModelCallEvidence - ) -> None: + def _set_evidence(row: ModelUsageEffect, prefix: str, evidence: ModelCallEvidence) -> None: """Copy validated ART custody fields into the selected attempt evidence side.""" for field, value in evidence.model_dump(mode="json", exclude={"media_type"}).items(): setattr(row, f"{prefix}_{field}", value) @staticmethod def _dispatch_result( - row: CheckerModelCallAttempt, + row: ModelUsageEffect, *, - slot: CheckerModelCallSlot | None = None, + slot: CheckerModelUsageSlot | None = None, replayed: bool, ) -> ModelDispatchReservation: """Project a dispatch reservation only from effect-bearing stored states.""" @@ -775,7 +764,7 @@ def _dispatch_result( if slot is None: raise ModelCallUnavailable("model_call_effect_unavailable") return ModelDispatchReservation( - slot_id=_uuid(row.slot_id), + slot_id=_uuid(row.checker_slot_id), attempt_id=_uuid(row.id), attempt_number=row.attempt_number, internal_call_request_id=_uuid(row.internal_call_request_id), @@ -816,11 +805,18 @@ async def attach_recovery( row = await self._repo.attempt(effect.attempt_id) if row is None: raise ModelCallUnavailable("model_call_attempt_unavailable") - slot = await self._session.get(CheckerModelCallSlot, row.slot_id) + slot = await self._session.get(CheckerModelUsageSlot, row.checker_slot_id) if slot is None: raise ModelCallUnavailable("model_call_attempt_unavailable") self._require_execution_slot(execution, _slot_key(slot)) + await self._repo.lock_envelope_scope(_slot_key(slot)) + if await self._repo.envelope(_slot_key(slot), lock=True) is None: + raise ModelCallUnavailable("model_call_envelope_unavailable") await self._repo.lock_slot_scope(_slot_key(slot)) + locked_slot = await self._repo.slot(_slot_key(slot), lock=True) + if locked_slot is None or locked_slot.id != slot.id: + raise ModelCallUnavailable("model_call_attempt_unavailable") + slot = locked_slot row = await self._repo.attempt(effect.attempt_id, lock=True) if row is not None: _require_effect(effect, slot, row) @@ -839,7 +835,7 @@ async def attach_recovery( ) if existing is None: await self._repo.add( - CheckerModelCallRecovery( + CheckerModelUsageRecovery( id=str(new_record_id()), attempt_id=str(effect.attempt_id), checker_run_id=str(execution.checker_run_id), @@ -880,11 +876,18 @@ async def attach_completed( row = await self._repo.attempt(effect.attempt_id) if row is None: raise ModelCallUnavailable("model_call_attempt_unavailable") - slot = await self._session.get(CheckerModelCallSlot, row.slot_id) + slot = await self._session.get(CheckerModelUsageSlot, row.checker_slot_id) if slot is None: raise ModelCallUnavailable("model_call_attempt_unavailable") self._require_execution_slot(execution, _slot_key(slot)) + await self._repo.lock_envelope_scope(_slot_key(slot)) + if await self._repo.envelope(_slot_key(slot), lock=True) is None: + raise ModelCallUnavailable("model_call_envelope_unavailable") await self._repo.lock_slot_scope(_slot_key(slot)) + locked_slot = await self._repo.slot(_slot_key(slot), lock=True) + if locked_slot is None or locked_slot.id != slot.id: + raise ModelCallUnavailable("model_call_attempt_unavailable") + slot = locked_slot row = await self._repo.attempt(effect.attempt_id, lock=True) if row is None or row.state != "completed": raise ModelCallUnavailable("model_call_completion_unavailable") @@ -897,7 +900,7 @@ async def attach_completed( ) if existing is None: await self._repo.add( - CheckerModelCallRecovery( + CheckerModelUsageRecovery( id=str(new_record_id()), attempt_id=str(effect.attempt_id), checker_run_id=str(execution.checker_run_id), @@ -1143,13 +1146,13 @@ async def mark_known_no_effect( async def _terminal_preflight( self, attempt_id: UUID - ) -> tuple[CheckerModelCallAttempt, CheckerModelCallSlot, ProjectModelProfile]: + ) -> tuple[ModelUsageEffect, CheckerModelUsageSlot, ProjectModelProfile]: """Load the attempt, slot, and exact profile before taking terminal locks.""" self._require_transaction() row = await self._repo.attempt(attempt_id) if row is None: raise ModelCallUnavailable("model_call_attempt_unavailable") - slot = await self._session.get(CheckerModelCallSlot, row.slot_id) + slot = await self._session.get(CheckerModelUsageSlot, row.checker_slot_id) if slot is None: raise ModelCallUnavailable("model_call_attempt_unavailable") profile_row = await self._repo.profile_exact( @@ -1160,17 +1163,23 @@ async def _terminal_preflight( return row, slot, stored_model_profile(profile_row) async def _lock_terminal( - self, slot: CheckerModelCallSlot, attempt_id: UUID - ) -> CheckerModelCallAttempt: - """Take slot-order custody, then lock the attempt and recheck its ancestry.""" + self, slot: CheckerModelUsageSlot, attempt_id: UUID + ) -> ModelUsageEffect: + """Take envelope then slot/effect custody before any account mutation.""" + await self._repo.lock_envelope_scope(_slot_key(slot)) + if await self._repo.envelope(_slot_key(slot), lock=True) is None: + raise ModelCallUnavailable("model_call_envelope_unavailable") await self._repo.lock_slot_scope(_slot_key(slot)) + locked_slot = await self._repo.slot(_slot_key(slot), lock=True) + if locked_slot is None or locked_slot.id != slot.id: + raise ModelCallUnavailable("model_call_attempt_unavailable") row = await self._repo.attempt(attempt_id, lock=True) - if row is None or row.slot_id != slot.id: + if row is None or row.checker_slot_id != slot.id: raise ModelCallUnavailable("model_call_attempt_unavailable") return row @staticmethod - def _require_effect_pending(row: CheckerModelCallAttempt) -> None: + def _require_effect_pending(row: ModelUsageEffect) -> None: """Require an effect-bearing nonterminal attempt with an active reservation.""" if row.state not in {"dispatching", "uncertain"}: raise ModelCallConflict("model_call_terminal_conflict") @@ -1179,8 +1188,8 @@ def _require_effect_pending(row: CheckerModelCallAttempt) -> None: async def _settle( self, - row: CheckerModelCallAttempt, - slot: CheckerModelCallSlot, + row: ModelUsageEffect, + slot: CheckerModelUsageSlot, cost: int, ) -> None: """Atomically move one reservation into the project's lifetime settled total.""" @@ -1193,7 +1202,7 @@ async def _settle( account.lifetime_reserved_nanodollars -= row.reserved_nanodollars account.lifetime_settled_nanodollars += cost - async def _release(self, row: CheckerModelCallAttempt, slot: CheckerModelCallSlot) -> None: + async def _release(self, row: ModelUsageEffect, slot: CheckerModelUsageSlot) -> None: """Return a proven no-effect reservation to the locked project account.""" await self._repo.lock_budget_scope(_uuid(slot.project_id)) account = await self._repo.budget_account(_uuid(slot.project_id), lock=True) @@ -1204,7 +1213,7 @@ async def _release(self, row: CheckerModelCallAttempt, slot: CheckerModelCallSlo account.lifetime_reserved_nanodollars -= row.reserved_nanodollars @staticmethod - def _set_usage(row: CheckerModelCallAttempt, usage: ModelCallUsage) -> None: + def _set_usage(row: ModelUsageEffect, usage: ModelCallUsage) -> None: """Persist every normalized usage counter as one inseparable terminal set.""" row.input_tokens = usage.input_tokens row.cached_input_tokens = usage.cached_input_tokens @@ -1212,13 +1221,13 @@ def _set_usage(row: CheckerModelCallAttempt, usage: ModelCallUsage) -> None: row.reasoning_tokens = usage.reasoning_tokens @staticmethod - def _completed_result(row: CheckerModelCallAttempt, *, replayed: bool) -> CompletedModelCall: + def _completed_result(row: ModelUsageEffect, *, replayed: bool) -> CompletedModelCall: """Project a completed call only when usage, evidence, cost, and time are complete.""" usage = _usage(row) if usage is None or row.terminal_at is None or row.settled_nanodollars is None: raise ModelCallUnavailable("model_call_completion_unavailable") return CompletedModelCall( - slot_id=_uuid(row.slot_id), + slot_id=_uuid(row.checker_slot_id), attempt_id=_uuid(row.id), attempt_number=row.attempt_number, internal_call_request_id=_uuid(row.internal_call_request_id), @@ -1232,13 +1241,13 @@ def _completed_result(row: CheckerModelCallAttempt, *, replayed: bool) -> Comple ) @staticmethod - def _failed_result(row: CheckerModelCallAttempt, *, replayed: bool) -> KnownFailedModelCall: + def _failed_result(row: ModelUsageEffect, *, replayed: bool) -> KnownFailedModelCall: """Project a known failed call only from complete billed terminal custody.""" usage = _usage(row) if row.terminal_at is None or usage is None or row.settled_nanodollars is None: raise ModelCallUnavailable("model_call_completion_unavailable") return KnownFailedModelCall( - slot_id=_uuid(row.slot_id), + slot_id=_uuid(row.checker_slot_id), attempt_id=_uuid(row.id), attempt_number=row.attempt_number, internal_call_request_id=_uuid(row.internal_call_request_id), @@ -1252,4 +1261,4 @@ def _failed_result(row: CheckerModelCallAttempt, *, replayed: bool) -> KnownFail ) -__all__ = ("ModelCallService", "model_recovery_candidates") +__all__ = ("ModelCallService",) diff --git a/backend/app/modules/model_usage/setup.py b/backend/app/modules/model_usage/setup.py new file mode 100644 index 000000000..cdf643592 --- /dev/null +++ b/backend/app/modules/model_usage/setup.py @@ -0,0 +1,556 @@ +"""Shared ledger service for exact setup-owned model-use subjects.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.model_usage.api import ( + CompletedSetupModelUsageEffect, + KnownNoEffectProof, + KnownNoEffectSetupModelUsageEffect, + ModelActionEvidence, + ModelCallBudgetUnavailable, + ModelCallConflict, + ModelCallEvidence, + ModelCallUsage, + ModelCallUnavailable, + ObservedSetupModelUsageEffect, + PreparedSetupModelUsageEffect, + SetupModelDispatchReservation, + SetupModelEffectAuthorityFacts, + SetupModelUsageAuthorityPort, + SetupModelUsageEvidencePort, + SetupModelUsageOwnerPort, + SetupModelUsageSlotKey, + model_action_digest, + model_retry_terminal_digest, + model_usage_cost, +) +from app.modules.model_usage.models import ( + ModelUsageEffect, + SetupModelUsageEnvelope, + SetupModelUsageSlot, +) +from app.modules.model_usage.profiles import stored_model_profile +from app.modules.model_usage.repository import ( + ModelCallRepository, + require_model_call_transaction, +) +from app.modules.model_usage.service import ( + _PROVIDER_RESPONSE_ADAPTER, + _evidence, + _sha, + _usage, +) + + +def _require_authority(action: str, facts: object, evidence: ModelActionEvidence) -> None: + if evidence.facts_digest != model_action_digest(action, facts): + raise ModelCallUnavailable("setup_model_authority_unavailable") + + +def _prepared( + envelope: SetupModelUsageEnvelope, + slot: SetupModelUsageSlot, + effect: ModelUsageEffect, + key: SetupModelUsageSlotKey, + *, + replayed: bool, +) -> PreparedSetupModelUsageEffect: + return PreparedSetupModelUsageEffect( + envelope_id=UUID(envelope.id), + slot_id=UUID(slot.id), + slot_key=key, + effect_id=UUID(effect.id), + attempt_number=effect.attempt_number, + internal_call_request_id=UUID(effect.internal_call_request_id), + request_sha256=effect.request_sha256, + replayed=replayed, + ) + + +def _slot_key(slot: SetupModelUsageSlot) -> SetupModelUsageSlotKey: + return SetupModelUsageSlotKey( + compilation_attempt_id=UUID(slot.compilation_attempt_id), + project_id=UUID(slot.project_id), + guide_id=UUID(slot.guide_id), + source_snapshot_id=UUID(slot.source_snapshot_id), + setup_run_id=UUID(slot.setup_run_id), + setup_generation=slot.setup_generation, + runtime_configuration_hash=slot.runtime_configuration_hash, + profile_id=UUID(slot.profile_id), + profile_version=slot.profile_version, + profile_digest=slot.profile_digest, + phase=slot.phase, + call_index=slot.call_index, + ) + + +def _effect(slot: SetupModelUsageSlot, row: ModelUsageEffect) -> SetupModelEffectAuthorityFacts: + key = _slot_key(slot) + return SetupModelEffectAuthorityFacts( + project_id=key.project_id, + slot_id=UUID(slot.id), + slot_key=key, + effect_id=UUID(row.id), + internal_call_request_id=UUID(row.internal_call_request_id), + ) + + +def _require_effect( + supplied: SetupModelEffectAuthorityFacts, + slot: SetupModelUsageSlot, + row: ModelUsageEffect, +) -> None: + if supplied != _effect(slot, row): + raise ModelCallUnavailable("setup_model_effect_unavailable") + + +class SetupModelUsageService: + """Allocate setup effects only after exact PROJECTS and AUTH custody.""" + + def __init__( + self, + session: AsyncSession, + *, + authority: SetupModelUsageAuthorityPort, + evidence: SetupModelUsageEvidencePort, + owner: SetupModelUsageOwnerPort, + ) -> None: + self._session = session + self._repo = ModelCallRepository(session) + self._authority = authority + self._evidence = evidence + self._owner = owner + + async def _profile(self, key: SetupModelUsageSlotKey): + row = await self._repo.profile_exact( + key.profile_id, key.profile_version, key.profile_digest + ) + if row is None: + raise ModelCallUnavailable("model_profile_unavailable") + profile = stored_model_profile(row) + if profile.spec.project_id != key.project_id: + raise ModelCallUnavailable("model_profile_unavailable") + return profile + + async def prepare( + self, key: SetupModelUsageSlotKey, request_sha256: str + ) -> PreparedSetupModelUsageEffect: + """Allocate one durable setup call identity before request persistence.""" + require_model_call_transaction(self._session) + key = SetupModelUsageSlotKey.model_validate(key) + facts = {"slot": key.model_dump(mode="json"), "request_sha256": request_sha256} + authority = await self._authority.authorize_setup_prepare(key, request_sha256) + _require_authority("setup_prepare", facts, authority) + await self._owner.require_setup_current(key) + profile = await self._profile(key) + if key.call_index >= profile.spec.maximum_calls: + raise ModelCallConflict("setup_model_call_index_conflict") + + await self._repo.lock_setup_envelope_scope(key) + envelope = await self._repo.setup_envelope(key, lock=True) + if envelope is None: + values = key.model_dump(mode="json", exclude={"phase", "call_index"}) + envelope = SetupModelUsageEnvelope(id=str(new_record_id()), **values) + await self._repo.add(envelope) + await self._repo.lock_setup_slot_scope(key) + slot = await self._repo.setup_slot(key, lock=True) + if slot is None: + slot = SetupModelUsageSlot( + id=str(new_record_id()), + envelope_id=envelope.id, + request_sha256=request_sha256, + **key.model_dump(mode="json"), + ) + await self._repo.add(slot) + elif slot.request_sha256 != request_sha256 or slot.envelope_id != envelope.id: + raise ModelCallConflict("setup_model_request_conflict") + effect = await self._repo.latest_setup_effect(slot.id, lock=True) + if effect is not None: + return _prepared(envelope, slot, effect, key, replayed=True) + effect = ModelUsageEffect( + id=str(new_record_id()), + setup_slot_id=slot.id, + attempt_number=1, + internal_call_request_id=str(new_record_id()), + request_sha256=request_sha256, + state="prepared", + prepared_authorization_evidence_id=str(authority.evidence_id), + ) + await self._repo.add(effect) + return _prepared(envelope, slot, effect, key, replayed=False) + + async def reserve_dispatch( + self, + prepared: PreparedSetupModelUsageEffect, + request_evidence: ModelCallEvidence, + ) -> SetupModelDispatchReservation: + """Reserve one actual setup provider dispatch under run and project caps.""" + require_model_call_transaction(self._session) + prepared = PreparedSetupModelUsageEffect.model_validate(prepared) + request_evidence = ModelCallEvidence.model_validate(request_evidence) + facts = { + "prepared": prepared.model_dump(mode="json"), + "request_evidence": request_evidence.model_dump(mode="json"), + } + authority = await self._authority.authorize_setup_dispatch(prepared, request_evidence) + _require_authority("setup_dispatch", facts, authority) + key = prepared.slot_key + await self._owner.require_setup_current(key) + await self._evidence.require_setup_request(prepared, request_evidence) + profile = await self._profile(key) + if ( + request_evidence.content_sha256 != prepared.request_sha256 + or request_evidence.byte_count > profile.spec.maximum_request_bytes + ): + raise ModelCallConflict("setup_model_request_evidence_conflict") + + await self._repo.lock_setup_envelope_scope(key) + envelope = await self._repo.setup_envelope(key, lock=True) + if envelope is None or envelope.id != str(prepared.envelope_id): + raise ModelCallUnavailable("setup_model_envelope_unavailable") + await self._repo.lock_setup_slot_scope(key) + slot = await self._repo.setup_slot(key, lock=True) + if slot is None or slot.id != str(prepared.slot_id): + raise ModelCallUnavailable("setup_model_effect_unavailable") + effect = await self._repo.attempt(prepared.effect_id, lock=True) + if effect is None or effect.setup_slot_id != slot.id: + raise ModelCallUnavailable("setup_model_effect_unavailable") + if effect.state != "prepared": + if effect.request_content_sha256 != request_evidence.content_sha256: + raise ModelCallConflict("setup_model_dispatch_conflict") + if effect.state != "dispatching": + raise ModelCallConflict("setup_model_effect_not_dispatchable") + return self._dispatch_result(envelope, slot, effect, key, request_evidence, True) + + totals = await self._repo.setup_envelope_totals(envelope.id) + reservation = profile.spec.maximum_dispatch_reservation_nanodollars + if ( + totals.dispatches >= profile.spec.maximum_calls + or totals.reserved_nanodollars + totals.settled_nanodollars + reservation + > profile.spec.maximum_reservation_nanodollars + or totals.request_bytes + request_evidence.byte_count + > profile.spec.maximum_total_request_bytes + or totals.response_bytes + + (totals.pending_dispatches + 1) * profile.spec.maximum_response_bytes + > profile.spec.maximum_total_response_bytes + or totals.input_tokens + + (totals.pending_dispatches + 1) * profile.spec.maximum_input_tokens + > profile.spec.maximum_total_input_tokens + or totals.output_tokens + + (totals.pending_dispatches + 1) * profile.spec.maximum_output_tokens + > profile.spec.maximum_total_output_tokens + ): + raise ModelCallBudgetUnavailable("model_run_envelope_unavailable") + await self._repo.lock_budget_scope(key.project_id) + account = await self._repo.budget_account(key.project_id, lock=True) + if account is None: + raise ModelCallBudgetUnavailable("model_budget_unavailable") + budget = await self._repo.current_budget_configuration(account) + if budget is None or ( + account.lifetime_reserved_nanodollars + + account.lifetime_settled_nanodollars + + reservation + > budget.lifetime_limit_nanodollars + ): + raise ModelCallBudgetUnavailable("model_budget_unavailable") + + for field, value in request_evidence.model_dump( + mode="json", exclude={"media_type"} + ).items(): + setattr(effect, f"request_{field}", value) + effect.budget_configuration_id = budget.id + effect.budget_configuration_version = budget.configuration_version + effect.budget_configuration_digest = budget.configuration_digest + effect.reserved_nanodollars = reservation + effect.dispatch_authorization_evidence_id = str(authority.evidence_id) + effect.dispatched_at = await self._repo.now() + effect.state = "dispatching" + account.lifetime_reserved_nanodollars += reservation + await self._repo.flush() + return self._dispatch_result(envelope, slot, effect, key, request_evidence, False) + + @staticmethod + def _dispatch_result( + envelope: SetupModelUsageEnvelope, + slot: SetupModelUsageSlot, + effect: ModelUsageEffect, + key: SetupModelUsageSlotKey, + evidence: ModelCallEvidence, + replayed: bool, + ) -> SetupModelDispatchReservation: + return SetupModelDispatchReservation( + envelope_id=UUID(envelope.id), + slot_id=UUID(slot.id), + slot_key=key, + effect_id=UUID(effect.id), + attempt_number=effect.attempt_number, + internal_call_request_id=UUID(effect.internal_call_request_id), + effect=_effect(slot, effect), + request_sha256=effect.request_sha256, + request_evidence=evidence, + budget_configuration_id=UUID(effect.budget_configuration_id), + budget_configuration_version=effect.budget_configuration_version, + budget_configuration_digest=effect.budget_configuration_digest, + reserved_nanodollars=effect.reserved_nanodollars, + replayed=replayed, + ) + + async def mark_uncertain( + self, + supplied: SetupModelEffectAuthorityFacts, + uncertainty_digest: str, + *, + provider_response_id: str | None = None, + ) -> ObservedSetupModelUsageEffect: + """Retain an ambiguous setup provider effect without releasing its reservation.""" + require_model_call_transaction(self._session) + supplied = SetupModelEffectAuthorityFacts.model_validate(supplied) + uncertainty_digest = _sha(uncertainty_digest) + if provider_response_id is not None: + provider_response_id = _PROVIDER_RESPONSE_ADAPTER.validate_python( + provider_response_id, strict=True + ) + authority = await self._authority.authorize_setup_observe( + supplied, provider_response_id, uncertainty_digest + ) + facts = { + "effect": supplied.model_dump(mode="json"), + "provider_response_id": provider_response_id, + "observation_digest": uncertainty_digest, + } + _require_authority("setup_observe", facts, authority) + row, slot, _ = await self._terminal_preflight(supplied.effect_id) + row = await self._lock_terminal(slot, supplied.effect_id) + _require_effect(supplied, slot, row) + if row.state == "uncertain": + if ( + row.uncertainty_digest != uncertainty_digest + or row.provider_response_id != provider_response_id + ): + raise ModelCallConflict("setup_model_uncertainty_conflict") + replayed = True + else: + if row.state != "dispatching": + raise ModelCallConflict("setup_model_uncertainty_conflict") + row.state = "uncertain" + row.uncertainty_digest = uncertainty_digest + row.provider_response_id = provider_response_id + row.observation_authorization_evidence_id = str(authority.evidence_id) + await self._repo.flush() + replayed = False + return ObservedSetupModelUsageEffect( + effect=_effect(slot, row), + uncertainty_digest=row.uncertainty_digest, + provider_response_id=row.provider_response_id, + replayed=replayed, + ) + + async def complete( + self, + supplied: SetupModelEffectAuthorityFacts, + *, + provider_response_id: str, + provider_model_snapshot: str, + response_evidence: ModelCallEvidence, + usage: ModelCallUsage, + ) -> CompletedSetupModelUsageEffect: + """Settle one verified setup response under narrow retained-effect authority.""" + require_model_call_transaction(self._session) + supplied = SetupModelEffectAuthorityFacts.model_validate(supplied) + provider_response_id = _PROVIDER_RESPONSE_ADAPTER.validate_python( + provider_response_id, strict=True + ) + response_evidence = ModelCallEvidence.model_validate(response_evidence) + usage = ModelCallUsage.model_validate(usage) + terminal_digest = canonical_json_hash( + { + "status": "completed", + "provider_response_id": provider_response_id, + "provider_model_snapshot": provider_model_snapshot, + "response_evidence": response_evidence.model_dump(mode="json"), + "usage": usage.model_dump(mode="json"), + } + ) + authority = await self._authority.authorize_setup_complete( + supplied, response_evidence, terminal_digest + ) + _require_authority( + "setup_complete", + { + "effect": supplied.model_dump(mode="json"), + "response_evidence": response_evidence.model_dump(mode="json"), + "terminal_digest": terminal_digest, + }, + authority, + ) + await self._evidence.require_setup_response(supplied.effect_id, response_evidence) + row, slot, profile = await self._terminal_preflight(supplied.effect_id) + if ( + provider_model_snapshot != profile.spec.model_snapshot + or response_evidence.byte_count > profile.spec.maximum_response_bytes + ): + raise ModelCallConflict("setup_model_response_conflict") + cost = model_usage_cost(profile.spec, usage) + row = await self._lock_terminal(slot, supplied.effect_id) + _require_effect(supplied, slot, row) + if row.state == "completed": + if ( + row.provider_response_id != provider_response_id + or _evidence(row, "response") != response_evidence + or _usage(row) != usage + or row.settled_nanodollars != cost + ): + raise ModelCallConflict("setup_model_terminal_conflict") + return self._completed(row, slot, replayed=True) + self._require_pending(row) + if row.provider_response_id not in {None, provider_response_id}: + raise ModelCallConflict("setup_model_provider_response_conflict") + await self._settle(row, slot, cost) + self._set_evidence(row, "response", response_evidence) + self._set_usage(row, usage) + row.provider_response_id = provider_response_id + row.settled_nanodollars = cost + row.terminal_authorization_evidence_id = str(authority.evidence_id) + row.terminal_at = await self._repo.now() + row.state = "completed" + await self._repo.flush() + return self._completed(row, slot, replayed=False) + + async def mark_known_no_effect( + self, + supplied: SetupModelEffectAuthorityFacts, + proof: KnownNoEffectProof, + ) -> KnownNoEffectSetupModelUsageEffect: + """Release only an authenticated setup dispatch proven to have no effect.""" + require_model_call_transaction(self._session) + supplied = SetupModelEffectAuthorityFacts.model_validate(supplied) + proof = KnownNoEffectProof.model_validate(proof) + terminal_digest = model_retry_terminal_digest(proof) + authority = await self._authority.authorize_setup_complete(supplied, None, terminal_digest) + _require_authority( + "setup_complete", + { + "effect": supplied.model_dump(mode="json"), + "response_evidence": None, + "terminal_digest": terminal_digest, + }, + authority, + ) + _, slot, _ = await self._terminal_preflight(supplied.effect_id) + row = await self._lock_terminal(slot, supplied.effect_id) + _require_effect(supplied, slot, row) + if row.state == "known_no_effect": + if ( + row.no_effect_proof_id != str(proof.proof_id) + or row.no_effect_proof_digest != proof.proof_digest + or row.no_effect_observed_at != proof.observed_at + ): + raise ModelCallConflict("setup_model_terminal_conflict") + replayed = True + else: + self._require_pending(row) + if row.provider_response_id is not None: + raise ModelCallConflict("setup_model_no_effect_conflict") + await self._release(row, slot) + row.no_effect_proof_id = str(proof.proof_id) + row.no_effect_proof_digest = proof.proof_digest + row.no_effect_observed_at = proof.observed_at + row.terminal_authorization_evidence_id = str(authority.evidence_id) + row.terminal_at = await self._repo.now() + row.state = "known_no_effect" + await self._repo.flush() + replayed = False + return KnownNoEffectSetupModelUsageEffect( + effect=_effect(slot, row), proof=proof, replayed=replayed + ) + + async def _terminal_preflight(self, effect_id: UUID): + row = await self._repo.attempt(effect_id) + if row is None or row.setup_slot_id is None: + raise ModelCallUnavailable("setup_model_effect_unavailable") + slot = await self._session.get(SetupModelUsageSlot, row.setup_slot_id) + if slot is None: + raise ModelCallUnavailable("setup_model_effect_unavailable") + return row, slot, await self._profile(_slot_key(slot)) + + async def _lock_terminal(self, slot: SetupModelUsageSlot, effect_id: UUID) -> ModelUsageEffect: + key = _slot_key(slot) + await self._repo.lock_setup_envelope_scope(key) + if await self._repo.setup_envelope(key, lock=True) is None: + raise ModelCallUnavailable("setup_model_envelope_unavailable") + await self._repo.lock_setup_slot_scope(key) + locked_slot = await self._repo.setup_slot(key, lock=True) + if locked_slot is None or locked_slot.id != slot.id: + raise ModelCallUnavailable("setup_model_effect_unavailable") + row = await self._repo.attempt(effect_id, lock=True) + if row is None or row.setup_slot_id != slot.id: + raise ModelCallUnavailable("setup_model_effect_unavailable") + return row + + @staticmethod + def _require_pending(row: ModelUsageEffect) -> None: + if row.state not in {"dispatching", "uncertain"}: + raise ModelCallConflict("setup_model_terminal_conflict") + if row.reserved_nanodollars is None: + raise ModelCallUnavailable("model_budget_unavailable") + + async def _settle(self, row: ModelUsageEffect, slot: SetupModelUsageSlot, cost: int) -> None: + await self._repo.lock_budget_scope(UUID(slot.project_id)) + account = await self._repo.budget_account(UUID(slot.project_id), lock=True) + if account is None or row.reserved_nanodollars is None: + raise ModelCallUnavailable("model_budget_unavailable") + if account.lifetime_reserved_nanodollars < row.reserved_nanodollars: + raise ModelCallUnavailable("model_budget_conservation_failed") + account.lifetime_reserved_nanodollars -= row.reserved_nanodollars + account.lifetime_settled_nanodollars += cost + + async def _release(self, row: ModelUsageEffect, slot: SetupModelUsageSlot) -> None: + await self._repo.lock_budget_scope(UUID(slot.project_id)) + account = await self._repo.budget_account(UUID(slot.project_id), lock=True) + if account is None or row.reserved_nanodollars is None: + raise ModelCallUnavailable("model_budget_unavailable") + if account.lifetime_reserved_nanodollars < row.reserved_nanodollars: + raise ModelCallUnavailable("model_budget_conservation_failed") + account.lifetime_reserved_nanodollars -= row.reserved_nanodollars + + @staticmethod + def _set_evidence(row: ModelUsageEffect, prefix: str, evidence: ModelCallEvidence) -> None: + for field, value in evidence.model_dump(mode="json", exclude={"media_type"}).items(): + setattr(row, f"{prefix}_{field}", value) + + @staticmethod + def _set_usage(row: ModelUsageEffect, usage: ModelCallUsage) -> None: + row.input_tokens = usage.input_tokens + row.cached_input_tokens = usage.cached_input_tokens + row.output_tokens = usage.output_tokens + row.reasoning_tokens = usage.reasoning_tokens + + @staticmethod + def _completed( + row: ModelUsageEffect, + slot: SetupModelUsageSlot, + *, + replayed: bool, + ) -> CompletedSetupModelUsageEffect: + usage = _usage(row) + if usage is None or row.terminal_at is None or row.settled_nanodollars is None: + raise ModelCallUnavailable("setup_model_completion_unavailable") + return CompletedSetupModelUsageEffect( + effect=_effect(slot, row), + provider_response_id=row.provider_response_id, + request_evidence=_evidence(row, "request"), + response_evidence=_evidence(row, "response"), + usage=usage, + settled_nanodollars=row.settled_nanodollars, + completed_at=row.terminal_at, + replayed=replayed, + ) + + +__all__ = ("SetupModelUsageService",) diff --git a/backend/app/modules/projects/guide_activation/service.py b/backend/app/modules/projects/guide_activation/service.py index c1b42824d..4e9e76713 100644 --- a/backend/app/modules/projects/guide_activation/service.py +++ b/backend/app/modules/projects/guide_activation/service.py @@ -14,7 +14,7 @@ validate_stored_external_checker_policies, ) from app.modules.checkers.api.external import ExternalCheckerRegistryPort -from app.modules.checkers.api.model_calls import ModelProfileReaderPort +from app.modules.model_usage.api import ModelProfileReaderPort from app.modules.projects.api.guide_activation import ( GuideActivationCommand, GuideActivationCondition, diff --git a/backend/app/modules/projects/guide_compilation/model_usage.py b/backend/app/modules/projects/guide_compilation/model_usage.py new file mode 100644 index 000000000..192d5ca57 --- /dev/null +++ b/backend/app/modules/projects/guide_compilation/model_usage.py @@ -0,0 +1,38 @@ +"""PROJECTS-owned currentness for shared setup model-use operations.""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.model_usage.api import ModelCallUnavailable, SetupModelUsageSlotKey +from app.modules.projects.guide_compilation.models import ProjectGuideCompilationAttempt + + +class SetupModelUsageCurrent: + """Lock the exact compilation attempt before any shared MODEL-USAGE lock.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def require_setup_current(self, key: SetupModelUsageSlotKey) -> None: + key = SetupModelUsageSlotKey.model_validate(key) + attempt_id = await self._session.scalar( + select(ProjectGuideCompilationAttempt.id) + .where( + ProjectGuideCompilationAttempt.id == key.compilation_attempt_id, + ProjectGuideCompilationAttempt.project_id == str(key.project_id), + ProjectGuideCompilationAttempt.guide_id == str(key.guide_id), + ProjectGuideCompilationAttempt.source_snapshot_id == str(key.source_snapshot_id), + ProjectGuideCompilationAttempt.setup_run_id == str(key.setup_run_id), + ProjectGuideCompilationAttempt.setup_generation == key.setup_generation, + ProjectGuideCompilationAttempt.runtime_configuration_hash + == key.runtime_configuration_hash, + ProjectGuideCompilationAttempt.status == "compilation_reserved", + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if attempt_id is None: + raise ModelCallUnavailable("setup_model_owner_unavailable") + + +__all__ = ("SetupModelUsageCurrent",) diff --git a/backend/app/modules/projects/guide_compilation/models.py b/backend/app/modules/projects/guide_compilation/models.py index 129642e8a..9720dc82b 100644 --- a/backend/app/modules/projects/guide_compilation/models.py +++ b/backend/app/modules/projects/guide_compilation/models.py @@ -100,6 +100,16 @@ class ProjectGuideCompilationAttempt(Base): "setup_generation", name="uq_compilation_attempt_exact_request", ), + UniqueConstraint( + "id", + "project_id", + "guide_id", + "source_snapshot_id", + "setup_run_id", + "setup_generation", + "runtime_configuration_hash", + name="uq_compilation_attempt_model_usage_owner", + ), CheckConstraint( "status in ('compilation_reserved','compilation_provider_uncertain'," "'provider_result_accepted','compilation_invalid_terminal'," diff --git a/backend/app/modules/projects/guide_compilation/proposal_approval.py b/backend/app/modules/projects/guide_compilation/proposal_approval.py index 34d6badb8..b3e15b6c1 100644 --- a/backend/app/modules/projects/guide_compilation/proposal_approval.py +++ b/backend/app/modules/projects/guide_compilation/proposal_approval.py @@ -18,7 +18,7 @@ from app.modules.checkers.api.policy_custody import CompiledPreSubmitCheckerPolicy from app.modules.checkers.api.external import ExternalCheckerRegistryPort from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection -from app.modules.checkers.api.model_calls import ModelProfileReaderPort +from app.modules.model_usage.api import ModelProfileReaderPort from app.modules.checkers.api.external_policy import ( LockedExternalCheckerPolicies, compile_external_checker_policy_bundle, diff --git a/backend/app/modules/projects/guide_compilation/proposal_service.py b/backend/app/modules/projects/guide_compilation/proposal_service.py index 61c3cb63b..8f5f1e507 100644 --- a/backend/app/modules/projects/guide_compilation/proposal_service.py +++ b/backend/app/modules/projects/guide_compilation/proposal_service.py @@ -32,7 +32,7 @@ from app.modules.projects.api.guide_documents import GuideDocumentManifestPort from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection from app.modules.checkers.api.external import ExternalCheckerRegistryPort -from app.modules.checkers.api.model_calls import ModelProfileReaderPort +from app.modules.model_usage.api import ModelProfileReaderPort from app.modules.projects.api.guide_proposal_package import GuideProposalReviewPackage from .proposal_repository import GuideProposalRepository diff --git a/backend/scripts/module_boundaries.py b/backend/scripts/module_boundaries.py index 39eef94af..3653cc703 100644 --- a/backend/scripts/module_boundaries.py +++ b/backend/scripts/module_boundaries.py @@ -22,7 +22,7 @@ / ".ci/auth-boundaries/IMPORT_LEDGER.md" ) MODULE_PREFIX = "app.modules." -BUSINESS_MODULES = 9 +BUSINESS_MODULES = 10 SUPPORTING_MODULES = 3 diff --git a/backend/tests/architecture/test_module_boundaries.py b/backend/tests/architecture/test_module_boundaries.py index ae9bcd583..88afd98b4 100644 --- a/backend/tests/architecture/test_module_boundaries.py +++ b/backend/tests/architecture/test_module_boundaries.py @@ -13,9 +13,7 @@ ROOT = Path(__file__).resolve().parents[3] REGISTRY = ROOT / ".ci/module-boundaries/registry.v1.json" LEDGER = ROOT / ".ci/module-boundaries/private-edge-debt.v1.json" -AUTH_LEDGER = ( - ROOT / ".ci/auth-boundaries/IMPORT_LEDGER.md" -) +AUTH_LEDGER = ROOT / ".ci/auth-boundaries/IMPORT_LEDGER.md" def _write(path: Path, value: str) -> None: @@ -32,14 +30,25 @@ def test_repository_matches_the_protected_module_boundary() -> None: boundary.validate(ROOT, REGISTRY, LEDGER, AUTH_LEDGER) -def test_registry_names_exactly_nine_business_and_three_supporting_modules() -> None: +def test_registry_names_exactly_ten_business_and_three_supporting_modules() -> None: """The canonical ownership map cannot silently grow or collapse.""" registry = boundary.load_registry(REGISTRY) - assert len(registry.business) == 9 + assert len(registry.business) == 10 assert len(registry.supporting) == 3 assert registry.names == { - "actors", "authorization", "projects", "tasks", "artifacts", "checkers", - "reviews", "contributions", "compensation", "audit", "outbox", "api_controls", + "actors", + "authorization", + "projects", + "tasks", + "artifacts", + "checkers", + "model_usage", + "reviews", + "contributions", + "compensation", + "audit", + "outbox", + "api_controls", } assert dict(registry.application_surfaces)["backend/app/interfaces"] == ( "legacy-interface-debt-tracked" @@ -58,9 +67,7 @@ def test_new_private_edge_fails_exact_ledger_comparison( ) graph = {name: set() for name in boundary.load_registry(REGISTRY).names} monkeypatch.setattr(boundary.authorization_boundary, "validate", lambda *_: None) - monkeypatch.setattr( - boundary.authorization_boundary, "load_ledger", lambda *_: (set(), set()) - ) + monkeypatch.setattr(boundary.authorization_boundary, "load_ledger", lambda *_: (set(), set())) monkeypatch.setattr(boundary, "scan", lambda *_: ({edge}, graph, set())) monkeypatch.setattr(boundary, "load_ledger", lambda *_: set()) monkeypatch.setattr(boundary, "_validate_public_apis", lambda *_: None) @@ -78,9 +85,7 @@ def test_owner_adapter_may_bind_only_its_own_private_implementation( "from app.modules.tasks.repository import TaskRepository\n" "from app.modules.projects.repository import ProjectRepository\n", ) - private, _, _ = boundary.scan( - tmp_path, boundary.load_registry(tmp_path / "registry.json") - ) + private, _, _ = boundary.scan(tmp_path, boundary.load_registry(tmp_path / "registry.json")) assert private == { boundary.PrivateEdge( "backend/app/adapters/tasks/__init__.py", @@ -100,9 +105,7 @@ def test_non_root_adapter_keeps_same_owner_private_debt_visible( tmp_path / "backend/app/adapters/tasks/worker.py", "from app.modules.tasks.repository import TaskRepository\n", ) - private, _, _ = boundary.scan( - tmp_path, boundary.load_registry(tmp_path / "registry.json") - ) + private, _, _ = boundary.scan(tmp_path, boundary.load_registry(tmp_path / "registry.json")) assert private == { boundary.PrivateEdge( "backend/app/adapters/tasks/worker.py", @@ -205,16 +208,13 @@ def test_auth_view_contains_only_cross_boundary_private_edges(tmp_path: Path) -> _registry(tmp_path / "registry.json") _write( tmp_path / "backend/app/modules/authorization/service.py", - "from app.modules.authorization import repository\n" - "from app.modules.tasks import models\n", + "from app.modules.authorization import repository\nfrom app.modules.tasks import models\n", ) _write( tmp_path / "backend/app/modules/tasks/service.py", "from app.modules.authorization import runtime\n", ) - _, _, auth_edges = boundary.scan( - tmp_path, boundary.load_registry(tmp_path / "registry.json") - ) + _, _, auth_edges = boundary.scan(tmp_path, boundary.load_registry(tmp_path / "registry.json")) assert auth_edges == { boundary.authorization_boundary.ImportEdge( "backend/app/modules/authorization/service.py", "app.modules.tasks" @@ -250,8 +250,7 @@ def test_tasks_public_api_has_no_private_or_mutable_dependency() -> None: imports.update(boundary.exact_source_imports(path, ROOT)) assert imports assert all( - not target.startswith("app.modules.") - or target.startswith("app.modules.tasks.api") + not target.startswith("app.modules.") or target.startswith("app.modules.tasks.api") for target in imports ) @@ -290,6 +289,7 @@ def test_checkers_public_api_has_no_private_or_mutable_dependency() -> None: not target.startswith("app.modules.") or target == "app.modules.checkers.api" or target.startswith("app.modules.checkers.api.") + or target == "app.modules.model_usage.api" for target in imports ) @@ -341,12 +341,14 @@ def test_general_ledger_rejects_copied_authorization_edges(tmp_path: Path) -> No """AUTH debt remains exclusively owned by the AUTH-003 ledger.""" document = { "schema_version": 1, - "edges": [{ - "source_file": "backend/app/modules/tasks/service.py", - "target_module": "authorization", - "imported_private_path": "app.modules.authorization.runtime", - "repair_owner": "security-triage-required", - }], + "edges": [ + { + "source_file": "backend/app/modules/tasks/service.py", + "target_module": "authorization", + "imported_private_path": "app.modules.authorization.runtime", + "repair_owner": "security-triage-required", + } + ], } path = tmp_path / "ledger.json" path.write_text(json.dumps(document), encoding="utf-8") @@ -358,12 +360,14 @@ def test_general_ledger_allows_non_auth_security_triage_owner(tmp_path: Path) -> """Unresolved authorization-affecting non-AUTH debt can block its capability.""" document = { "schema_version": 1, - "edges": [{ - "source_file": "backend/app/modules/tasks/service.py", - "target_module": "actors", - "imported_private_path": "app.modules.actors.models", - "repair_owner": "security-triage-required", - }], + "edges": [ + { + "source_file": "backend/app/modules/tasks/service.py", + "target_module": "actors", + "imported_private_path": "app.modules.actors.models", + "repair_owner": "security-triage-required", + } + ], } path = tmp_path / "ledger.json" path.write_text(json.dumps(document), encoding="utf-8") @@ -407,10 +411,13 @@ def test_initial_ledgers_capture_high_risk_application_edges() -> None: for source, target in actual ) auth_inbound, _ = boundary.authorization_boundary.load_ledger(AUTH_LEDGER) - assert boundary.authorization_boundary.ImportEdge( - "backend/app/interfaces/artifact_operations.py", - "app.modules.authorization.prepared", - ) in auth_inbound + assert ( + boundary.authorization_boundary.ImportEdge( + "backend/app/interfaces/artifact_operations.py", + "app.modules.authorization.prepared", + ) + in auth_inbound + ) def test_metadata_discovery_exception_is_exact_and_model_only(tmp_path: Path) -> None: @@ -423,9 +430,7 @@ def test_metadata_discovery_exception_is_exact_and_model_only(tmp_path: Path) -> assert not edges _write(path, "from app.modules.tasks import repository\n") edges, _, _ = boundary.scan(tmp_path, registry) - assert {edge.imported_private_path for edge in edges} == { - "app.modules.tasks.repository" - } + assert {edge.imported_private_path for edge in edges} == {"app.modules.tasks.repository"} @pytest.mark.parametrize("mutation", ("missing", "additional")) @@ -480,8 +485,7 @@ def test_exact_auth_adapter_root_uses_owner_parity_without_nested_exception( _registry(tmp_path / "registry.json") _write( tmp_path / "backend/app/adapters/auth/__init__.py", - "import app.modules.authorization.runtime\n" - "import app.modules.tasks.repository\n", + "import app.modules.authorization.runtime\nimport app.modules.tasks.repository\n", ) nested = "backend/app/adapters/auth/adapter_bindings.py" _write( @@ -515,9 +519,7 @@ def test_exact_auth_adapter_root_uses_owner_parity_without_nested_exception( "from app.modules.projects.repository import *\n", ), ) -def test_dynamic_and_wildcard_imports_fail_repository_wide( - tmp_path: Path, source: str -) -> None: +def test_dynamic_and_wildcard_imports_fail_repository_wide(tmp_path: Path, source: str) -> None: """The general scanner inherits AUTH's fail-closed AST parser.""" _registry(tmp_path / "registry.json") _write(tmp_path / "backend/app/modules/tasks/service.py", source) diff --git a/backend/tests/authorization/admin_access/test_admin_reads_postgresql.py b/backend/tests/authorization/admin_access/test_admin_reads_postgresql.py index 1478ed9cd..d8ad8b3ca 100644 --- a/backend/tests/authorization/admin_access/test_admin_reads_postgresql.py +++ b/backend/tests/authorization/admin_access/test_admin_reads_postgresql.py @@ -183,7 +183,7 @@ async def test_missing_admin_target_has_concealed_response_and_no_success_effect assert await authority_snapshot() == before -@pytest.mark.parametrize("surface,total", [("permissions", 91), ("admin-role-definitions", 5)]) +@pytest.mark.parametrize("surface,total", [("permissions", 95), ("admin-role-definitions", 5)]) async def test_admin_catalogue_read_returns_public_definitions( admin_access: AdminAccess, surface: str, diff --git a/backend/tests/authorization/admin_access/test_grant_reads_postgresql.py b/backend/tests/authorization/admin_access/test_grant_reads_postgresql.py index 53372d923..21f2bfc3a 100644 --- a/backend/tests/authorization/admin_access/test_grant_reads_postgresql.py +++ b/backend/tests/authorization/admin_access/test_grant_reads_postgresql.py @@ -92,7 +92,7 @@ async def test_system_auditor_has_exact_read_surface( assert response.status_code == 200, response.text body = response.json() if surface in {"permissions", "definitions"}: - assert body["total"] == len(body["items"]) == (91 if surface == "permissions" else 5) + assert body["total"] == len(body["items"]) == (95 if surface == "permissions" else 5) elif surface in {"list", "history"}: expected = {grant_id, access.bootstrap_grant_id} if surface == "list" else {grant_id} assert body["total"] == len(expected) diff --git a/backend/tests/authorization/catalogue_fixtures.py b/backend/tests/authorization/catalogue_fixtures.py index 5228d75da..0fd9fe372 100644 --- a/backend/tests/authorization/catalogue_fixtures.py +++ b/backend/tests/authorization/catalogue_fixtures.py @@ -39,6 +39,10 @@ ServiceIdentity.PROJECT_SETUP: { "project.guide_compilation.request_automatic", "project.guide_compilation.execute", + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.observe", + "project.guide_compilation.model.complete", "project.guide_sufficiency.run", "project.submission_artifact_policy.derive", "project.post_submit_checker_policy.derive", @@ -274,6 +278,10 @@ review.queue.override project.guide_compilation.request project.guide_compilation.execute checker.model_call.prepare checker.model_call.dispatch checker.model_call.recover checker.model_call.observe checker.model_call.complete checker.model_call.grant + project.guide_compilation.model.prepare + project.guide_compilation.model.dispatch + project.guide_compilation.model.observe + project.guide_compilation.model.complete project.model_profile.manage project.model_budget.manage project.model_call.recovery_child.authorize""".split() ) @@ -308,6 +316,22 @@ "checker.model_call.observe": ("checker.model_call.observe", "PILOT-05"), "checker.model_call.complete": ("checker.model_call.complete", "PILOT-05"), "checker.model_call.grant": ("checker.model_call.grant", "PILOT-05"), + "project.guide_compilation.model.prepare": ( + "project.guide_compilation.model.prepare", + "PILOT-05", + ), + "project.guide_compilation.model.dispatch": ( + "project.guide_compilation.model.dispatch", + "PILOT-05", + ), + "project.guide_compilation.model.observe": ( + "project.guide_compilation.model.observe", + "PILOT-05", + ), + "project.guide_compilation.model.complete": ( + "project.guide_compilation.model.complete", + "PILOT-05", + ), "project.model_profile.provision": ("project.model_profile.manage", "PILOT-05"), "project.model_budget.configure": ("project.model_budget.manage", "PILOT-05"), "project.model_call.recovery_child.authorize": ( diff --git a/backend/tests/authorization/checker_models/test_contracts.py b/backend/tests/authorization/checker_models/test_contracts.py index 9c762e832..941145eb6 100644 --- a/backend/tests/authorization/checker_models/test_contracts.py +++ b/backend/tests/authorization/checker_models/test_contracts.py @@ -12,8 +12,8 @@ from app.core.identifiers import new_record_id from app.modules.actors.api import ServiceIdentity from app.modules.authorization.catalogue import ActionId, PermissionId -from app.modules.authorization.checker_model_authorization import CheckerModelAuthorization -from app.modules.authorization.domain.checker_models import ( +from app.modules.authorization.model_usage_authorization import CheckerModelAuthorization +from app.modules.authorization.domain.model_usage import ( GRANT, RECOVER, ModelCallGrantAuthorityFacts, @@ -37,7 +37,7 @@ MatchedAuthorityKind, ServiceAuthorizationContext, ) -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelCallEffectAuthorityFacts, ModelCallExecutionFacts, ModelCallUnavailable, diff --git a/backend/tests/authorization/test_catalogue.py b/backend/tests/authorization/test_catalogue.py index dc4519eda..27fb72a4b 100644 --- a/backend/tests/authorization/test_catalogue.py +++ b/backend/tests/authorization/test_catalogue.py @@ -8,25 +8,38 @@ from app.modules.authorization.admin_service import AdminRoleGrantService from app.modules.authorization.catalogue import ( - ACTION_IDS, ACTION_DEFINITIONS, ACTION_BY_ID, PERMISSION_IDS, - HISTORICAL_PERMISSION_IDS, NEW_PERMISSION_IDS, ActionId, PermissionId, - ActionOwner, ActionAvailability, resolve_executable_action, - FUTURE_INTENT_REQUIRED_ACTIONS, SERVICE_ACTIONS_BY_IDENTITY, + ACTION_IDS, + ACTION_DEFINITIONS, + ACTION_BY_ID, + PERMISSION_IDS, + HISTORICAL_PERMISSION_IDS, + NEW_PERMISSION_IDS, + ActionId, + PermissionId, + ActionOwner, + ActionAvailability, + resolve_executable_action, + FUTURE_INTENT_REQUIRED_ACTIONS, + SERVICE_ACTIONS_BY_IDENTITY, ) from tests.authorization.catalogue_fixtures import ( - ART_CUSTODY_EXPECTATIONS, REV_CUSTODY_EXPECTATIONS, FIXED_SERVICE_ACTION_EXPECTATIONS, + ART_CUSTODY_EXPECTATIONS, + REV_CUSTODY_EXPECTATIONS, + FIXED_SERVICE_ACTION_EXPECTATIONS, ) def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> None: from tests.authorization.catalogue_fixtures import ( - historical_permissions, new_permissions, expected + historical_permissions, + new_permissions, + expected, ) assert {item.value for item in HISTORICAL_PERMISSION_IDS} == historical_permissions assert {item.value for item in NEW_PERMISSION_IDS} == new_permissions assert {item.value for item in PERMISSION_IDS} == historical_permissions | new_permissions - assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 186 + assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 190 assert set(ACTION_BY_ID) == ACTION_IDS assert {definition.owner for definition in ACTION_DEFINITIONS} == set(ActionOwner) assert { @@ -105,10 +118,13 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> } assert all(not owner.value.startswith("WS-REV-") for owner in ActionOwner) assert Counter(definition.availability for definition in ACTION_DEFINITIONS) == { - ActionAvailability.ACTIVE: 162, + ActionAvailability.ACTIVE: 166, ActionAvailability.PLANNED: 24, } - assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is PermissionId.ACTOR_PROFILE_READ_SELF + assert ( + resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id + is PermissionId.ACTOR_PROFILE_READ_SELF + ) with pytest.raises(ValueError, match="not active"): resolve_executable_action(ActionId.REVIEW_QUEUE_READ) with pytest.raises(TypeError): @@ -132,7 +148,7 @@ def test_proposal_actions_have_exact_executable_authority(action): def test_permission_definition_response_is_exact() -> None: permission_response = AdminRoleGrantService.permission_definitions() - assert permission_response.total == 91 + assert permission_response.total == 95 assert [item.permission_id.value for item in permission_response.items] == sorted( permission.value for permission in PermissionId ) @@ -147,7 +163,7 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> identity: {action.value for action in actions} for identity, actions in SERVICE_ACTIONS_BY_IDENTITY.items() } == expected - assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 38 + assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 42 assert FUTURE_INTENT_REQUIRED_ACTIONS == { ActionId.REVIEW_FINDING_EVIDENCE_INGEST, ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, @@ -178,6 +194,26 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> ActionOwner.AUTH_12I, ActionAvailability.ACTIVE, ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionOwner.PILOT_05, + ActionAvailability.ACTIVE, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionOwner.PILOT_05, + ActionAvailability.ACTIVE, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionOwner.PILOT_05, + ActionAvailability.ACTIVE, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + ActionOwner.PILOT_05, + ActionAvailability.ACTIVE, + ), ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: ( PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12E, @@ -216,8 +252,8 @@ def test_active_fixed_service_actions_are_exact(): ActionId.CHECKER_MODEL_CALL_GRANT, ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, - ActionId.TASK_REVISION_EXPIRE, - ActionId.TASK_REVISION_RECOVER, + ActionId.TASK_REVISION_EXPIRE, + ActionId.TASK_REVISION_RECOVER, ActionId.OUTBOX_DISPATCH, ActionId.TASK_POST_SUBMIT_ROUTE, ActionId.ARTIFACT_VERIFICATION_EXECUTE, @@ -230,6 +266,10 @@ def test_active_fixed_service_actions_are_exact(): ActionId.ARTIFACT_GUIDE_SOURCE_READ, ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, ActionId.PROJECT_SETUP_RUN_UPDATE, diff --git a/backend/tests/checker_output_admission_helpers.py b/backend/tests/checker_output_admission_helpers.py index 085345a7c..7f11c3a2a 100644 --- a/backend/tests/checker_output_admission_helpers.py +++ b/backend/tests/checker_output_admission_helpers.py @@ -1,14 +1,18 @@ """Controlled complete CHECKERS owner facts for ART output-custody tests.""" +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta +import re from uuid import UUID -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.core.identifiers import new_record_id from app.core.hashing import canonical_json_hash from app.modules.actors.api import ServiceIdentity +from app.modules.actors.models import ActorProfile from app.modules.actors.service import ActorService from app.modules.artifacts.schemas import ( ArtifactAuthorityDeniedError, @@ -32,15 +36,59 @@ CheckerOutputSelector, CheckerOutputSlot, ) -from app.modules.checkers.api.model_calls import ModelCallExecutionFacts, ModelPaidSlotKey +from app.modules.model_usage.api import ModelCallExecutionFacts, ModelPaidSlotKey from app.modules.checkers.execution_repository import stored_request -from app.modules.checkers.model_calls.models import ( - CheckerModelCallAttempt, - CheckerModelCallSlot, - CheckerModelProfile, +from app.modules.model_usage.models import ( + CheckerModelUsageEnvelope, + CheckerModelUsageSlot, + ModelUsageEffect, + ModelUsageProfile, ) -from app.modules.checkers.models import CheckerRun, ExternalCheckerRegistryEntryRecord -from app.modules.tasks.models import Submission, WorkstreamTask +from app.modules.checkers.models import CheckerRun +from app.modules.tasks.models import AuditEvent, Submission, WorkstreamTask + + +_ISOLATED_DATABASE_RE = re.compile(r"workstream_test_([a-f0-9]{12})") +_ISOLATED_ROLE_RE = re.compile(r"workstream_role_([a-f0-9]{12})") +_CONTROLLED_MODEL_GUARDS = { + "model_usage_profiles": "model_usage_profiles_guard", + "model_usage_effects": "model_usage_effects_guard", +} + + +@asynccontextmanager +async def _suspend_controlled_model_guard( + session: AsyncSession, *, table: str +) -> AsyncIterator[None]: + """Suspend one named guard only in the isolated database for this ART fixture.""" + trigger = _CONTROLLED_MODEL_GUARDS.get(table) + if trigger is None: + raise RuntimeError("unsupported model usage guard suspension") + database_name, database_role = ( + await session.execute(text("select current_database(), current_user")) + ).one() + database_match = _ISOLATED_DATABASE_RE.fullmatch(str(database_name)) + role_match = _ISOLATED_ROLE_RE.fullmatch(str(database_role)) + if ( + database_match is None + or role_match is None + or database_match.group(1) != role_match.group(1) + ): + raise RuntimeError("model usage guard suspension requires an isolated test database") + try: + await session.execute(text(f"alter table {table} disable trigger {trigger}")) + yield + # PostgreSQL will not ALTER a table with deferred FK events pending. This + # fixture owns its dedicated arrangement session, so finish the controlled + # seed before restoring the guard in a new transaction. + await session.commit() + await session.execute(text(f"alter table {table} enable trigger {trigger}")) + await session.commit() + except BaseException: + await session.rollback() + await session.execute(text(f"alter table {table} enable trigger {trigger}")) + await session.commit() + raise async def make_checker_output_reservation( @@ -52,7 +100,7 @@ async def make_checker_output_reservation( maximum_bytes: int = 1024, content_sha256: str | None = None, ) -> CheckerOutputReservation: - """Create one call-stable model effect beneath an actual seeded run.""" + """Create one controlled effect beneath an actual seeded run for ART tests.""" checker_run = await session.get(CheckerRun, str(checker_run_id)) assert checker_run is not None submission = await session.scalar( @@ -78,13 +126,11 @@ async def make_checker_output_reservation( submission.version, UUID(submission.task_assignment_id), ) - registry = await session.scalar(select(ExternalCheckerRegistryEntryRecord).limit(1)) - assert registry is not None profile_version = ( int( await session.scalar( - select(func.coalesce(func.max(CheckerModelProfile.profile_version), 0)).where( - CheckerModelProfile.project_id == str(evaluation.project_id) + select(func.coalesce(func.max(ModelUsageProfile.profile_version), 0)).where( + ModelUsageProfile.project_id == str(evaluation.project_id) ) ) or 0 @@ -92,27 +138,36 @@ async def make_checker_output_reservation( + 1 ) profile_id = new_record_id() + actor_id = await session.scalar(select(ActorProfile.id).order_by(ActorProfile.id).limit(1)) + audit_event_id = await session.scalar(select(AuditEvent.id).order_by(AuditEvent.id).limit(1)) + assert actor_id is not None and audit_event_id is not None + per_call_bytes = max(maximum_bytes, 1024) profile_digest = canonical_json_hash( {"project_id": str(evaluation.project_id), "profile_id": str(profile_id)} ) - profile = CheckerModelProfile( + profile = ModelUsageProfile( id=str(profile_id), project_id=str(evaluation.project_id), profile_version=profile_version, profile_digest=profile_digest, - registry_entry_id=str(registry.id), - registry_entry_digest=registry.entry_digest, provider="test_provider", api="test.responses.v1", model_snapshot="test-model-2026-10-01", generation_settings={"reasoning_effort": "low", "text_verbosity": "low"}, - maximum_request_bytes=maximum_bytes, - maximum_output_bytes=maximum_bytes, - maximum_response_bytes=max(maximum_bytes, 6 * maximum_bytes + 4096), + maximum_request_bytes=per_call_bytes, + maximum_output_bytes=per_call_bytes, + maximum_response_bytes=6 * per_call_bytes + 4096, maximum_input_tokens=1024, maximum_output_tokens=1024, + maximum_calls=1, + maximum_total_request_bytes=per_call_bytes, + maximum_total_response_bytes=6 * per_call_bytes + 4096, + maximum_total_input_tokens=1024, + maximum_total_output_tokens=1024, + allowed_tools=(), + maximum_tool_calls=0, + maximum_tool_output_bytes=0, deadline_ms=30_000, - call_slots=1, pricing_policy_id=str(new_record_id()), pricing_policy_version=1, pricing_policy_digest=canonical_json_hash({"profile_id": str(profile_id), "rate": 1}), @@ -120,10 +175,14 @@ async def make_checker_output_reservation( cached_input_rate=1, output_rate=1, operation_id=str(new_record_id()), - actor_profile_id=str(new_record_id()), - authorization_evidence_id=str(new_record_id()), + actor_profile_id=str(actor_id), + authorization_evidence_id=str(audit_event_id), ) session.add(profile) + # This fixture proves ART admission and intentionally does not reproduce MODEL-USAGE + # authorization/evidence setup. Dedicated real-PG tests exercise both guards. + async with _suspend_controlled_model_guard(session, table="model_usage_profiles"): + await session.flush() configuration_sha256 = canonical_json_hash({"profile_id": str(profile_id)}) paid_slot = ModelPaidSlotKey( project_id=evaluation.project_id, @@ -131,7 +190,7 @@ async def make_checker_output_reservation( submission_id=evaluation.submission_id, submission_version=evaluation.submission_version, requirement_id="model_evidence", - registry_entry_digest=registry.entry_digest, + registry_entry_digest=canonical_json_hash({"checker_run_id": checker_run.id}), configuration_sha256=configuration_sha256, profile_id=profile_id, profile_version=profile_version, @@ -140,10 +199,31 @@ async def make_checker_output_reservation( digest = content_sha256 or canonical_json_hash( {"model_call": str(profile_id), "role": slot_key} ) - model_slot_id, attempt_id = new_record_id(), new_record_id() + envelope_id, model_slot_id, attempt_id = ( + new_record_id(), + new_record_id(), + new_record_id(), + ) + session.add( + CheckerModelUsageEnvelope( + id=str(envelope_id), + project_id=str(evaluation.project_id), + task_id=str(evaluation.task_id), + assignment_id=str(evaluation.assignment_id), + submission_id=str(evaluation.submission_id), + submission_version=evaluation.submission_version, + requirement_id=paid_slot.requirement_id, + registry_entry_digest=paid_slot.registry_entry_digest, + configuration_sha256=paid_slot.configuration_sha256, + profile_id=str(profile_id), + profile_version=profile_version, + profile_digest=profile_digest, + ) + ) session.add( - CheckerModelCallSlot( + CheckerModelUsageSlot( id=str(model_slot_id), + envelope_id=str(envelope_id), project_id=str(evaluation.project_id), task_id=str(evaluation.task_id), assignment_id=str(evaluation.assignment_id), @@ -159,18 +239,20 @@ async def make_checker_output_reservation( first_request_sha256=digest, ) ) + await session.flush() session.add( - CheckerModelCallAttempt( + ModelUsageEffect( id=str(attempt_id), - slot_id=str(model_slot_id), + checker_slot_id=str(model_slot_id), attempt_number=1, internal_call_request_id=str(new_record_id()), request_sha256=digest, state="dispatching", - prepared_authorization_evidence_id=str(new_record_id()), + prepared_authorization_evidence_id=str(audit_event_id), ) ) - await session.flush() + async with _suspend_controlled_model_guard(session, table="model_usage_effects"): + await session.flush() execution = ModelCallExecutionFacts( project_id=evaluation.project_id, task_id=evaluation.task_id, diff --git a/backend/tests/checker_output_custody_helpers.py b/backend/tests/checker_output_custody_helpers.py index 65f10c389..52df710d5 100644 --- a/backend/tests/checker_output_custody_helpers.py +++ b/backend/tests/checker_output_custody_helpers.py @@ -54,7 +54,7 @@ CheckerOutputSelector, CheckerOutputUnavailable, ) -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelCallEvidence, ModelCallExecutionFacts, ModelPaidSlotKey, @@ -62,13 +62,13 @@ ) from app.modules.checkers.execution_repository import stored_request from app.modules.checkers.models import CheckerRun, ExternalCheckerRegistryEntryRecord -from app.modules.checkers.model_calls.service import ModelCallService +from app.modules.model_usage.service import ModelCallService from app.adapters.auth import checker_model_service_authorization from app.modules.authorization.checker_output_authorization import ( PreparedCheckerOutputBindingAuthorization, PreparedCheckerOutputWriteAuthorization, ) -from app.modules.checkers.model_calls.models import CheckerModelCallAttempt +from app.modules.model_usage.models import ModelUsageEffect from tests.artifact_store_helpers import ( artifact_admission_limit_settings, artifact_preparation_limits, @@ -133,10 +133,10 @@ def __init__(self, session: AsyncSession, state: ControlledReservationState) -> async def resolve(self, selector: CheckerOutputSelector) -> CheckerOutputReservation: """Fence the selected call attempt before ART acquires any artifact row lock.""" attempt = await self._session.scalar( - select(CheckerModelCallAttempt) + select(ModelUsageEffect) .where( - CheckerModelCallAttempt.id == str(selector.model_call_attempt_id), - CheckerModelCallAttempt.slot_id == str(selector.model_call_slot_id), + ModelUsageEffect.id == str(selector.model_call_attempt_id), + ModelUsageEffect.checker_slot_id == str(selector.model_call_slot_id), ) .with_for_update() .execution_options(populate_existing=True) @@ -590,11 +590,7 @@ async def _provision_model_policy(factory, namespace, admin_access): assert registry is not None project_id = policy_bundle[0]["project"] spec = profile_spec(project_id).model_copy( - update={ - "registry_entry_id": UUID(str(registry.id)), - "registry_entry_digest": registry.entry_digest, - "maximum_request_bytes": 1024, - } + update={"maximum_request_bytes": 1024, "maximum_total_request_bytes": 1024} ) response = await admin_access.signed.client.post( f"/api/v1/operations/projects/{project_id}/model-profiles", @@ -608,6 +604,8 @@ async def _provision_model_policy(factory, namespace, admin_access): json={ "project_id": str(project_id), "configuration_version": 1, + "authorized_total_nanodollars": 10_000_000_000, + "externally_reconciled_prior_spend_nanodollars": 0, "lifetime_limit_nanodollars": 10_000_000_000, }, ) diff --git a/backend/tests/checkers/external/test_policy.py b/backend/tests/checkers/external/test_policy.py index 19ad8d4b2..b0fce6977 100644 --- a/backend/tests/checkers/external/test_policy.py +++ b/backend/tests/checkers/external/test_policy.py @@ -32,7 +32,7 @@ selected_post_submit_execution_budget_ms, selected_pre_submit_execution_budget_ms, ) -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelGenerationSettings, ModelPricing, ProjectModelProfile, @@ -141,8 +141,6 @@ def _model_profile(project_id, entry) -> ProjectModelProfile: spec = ProjectModelProfileSpec( project_id=project_id, profile_version=1, - registry_entry_id=entry.registry_entry_id, - registry_entry_digest=entry.entry_digest, provider="openai_responses", api="responses.v1", model_snapshot="gpt-example-2026-10-01", @@ -152,6 +150,14 @@ def _model_profile(project_id, entry) -> ProjectModelProfile: maximum_response_bytes=30_000, maximum_input_tokens=100, maximum_output_tokens=50, + maximum_calls=1, + maximum_total_request_bytes=10_000, + maximum_total_response_bytes=30_000, + maximum_total_input_tokens=100, + maximum_total_output_tokens=50, + allowed_tools=(), + maximum_tool_calls=0, + maximum_tool_output_bytes=0, deadline_ms=30_000, pricing=pricing, ) @@ -290,8 +296,6 @@ async def test_model_configuration_requires_exact_post_profile_scope(): assert selector == profile.selector assert scope == { "project_id": project_id, - "registry_entry_id": post.registry_entry_id, - "registry_entry_digest": post.entry_digest, } diff --git a/backend/tests/checkers_model_calls/test_composed_execution.py b/backend/tests/checkers_model_calls/test_composed_execution.py index b6b8808c1..73684ce7e 100644 --- a/backend/tests/checkers_model_calls/test_composed_execution.py +++ b/backend/tests/checkers_model_calls/test_composed_execution.py @@ -32,9 +32,9 @@ from app.modules.actors.api import ServiceIdentity from app.modules.artifacts.preparation import HARD_MAXIMUM_ARTIFACT_BYTES from app.modules.checkers.api.external import ExternalCheckerSchema, external_checker_json_hash -from app.modules.checkers.model_calls.models import ( - CheckerModelCallAttempt, - CheckerModelBudgetAccount, +from app.modules.model_usage.models import ( + ModelUsageEffect, + ModelUsageBudgetAccount, ) from app.adapters.checkers.model_proxy import CheckerModelProxy from tests.checkers.external.support import post_registry_spec @@ -329,13 +329,13 @@ async def execute(self, request, grant, *, model_grant=None): assert len(creates) == (2 if terminal_mode else 1) async with h.factory() as session: assert ( - await session.scalar(select(func.count()).select_from(CheckerModelCallAttempt)) + await session.scalar(select(func.count()).select_from(ModelUsageEffect)) == (2 if terminal_mode else 1) ) attempt = await session.scalar( - select(CheckerModelCallAttempt).where(CheckerModelCallAttempt.state == "completed") + select(ModelUsageEffect).where(ModelUsageEffect.state == "completed") ) - account = await session.get(CheckerModelBudgetAccount, str(h.request.project_id)) + account = await session.get(ModelUsageBudgetAccount, str(h.request.project_id)) assert attempt.state == "completed" assert account.lifetime_reserved_nanodollars == 0 assert account.lifetime_settled_nanodollars == ( diff --git a/backend/tests/checkers_model_calls/test_model_call_contract.py b/backend/tests/checkers_model_calls/test_model_call_contract.py index edd5142aa..1a19643d4 100644 --- a/backend/tests/checkers_model_calls/test_model_call_contract.py +++ b/backend/tests/checkers_model_calls/test_model_call_contract.py @@ -7,14 +7,16 @@ from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelCallConflict, ModelCallUsage, ModelGenerationSettings, ModelProfileProvisioningFacts, ModelPaidSlotKey, ModelPricing, + ProjectModelBudgetConfigurationSpec, ProjectModelProfileSpec, + SetupModelUsageSlotKey, model_usage_cost, ) @@ -27,8 +29,6 @@ def profile_spec() -> ProjectModelProfileSpec: return ProjectModelProfileSpec( project_id=new_record_id(), profile_version=3, - registry_entry_id=new_record_id(), - registry_entry_digest=sha("registry"), provider="openai_responses", api="responses.v1", model_snapshot="gpt-example-2026-10-01", @@ -41,6 +41,14 @@ def profile_spec() -> ProjectModelProfileSpec: maximum_response_bytes=20_000, maximum_input_tokens=100, maximum_output_tokens=50, + maximum_calls=1, + maximum_total_request_bytes=10_000, + maximum_total_response_bytes=20_000, + maximum_total_input_tokens=100, + maximum_total_output_tokens=50, + allowed_tools=(), + maximum_tool_calls=0, + maximum_tool_output_bytes=0, deadline_ms=60_000, pricing=ModelPricing( pricing_policy_id=new_record_id(), @@ -67,10 +75,54 @@ def test_profile_requires_room_for_canonical_response_evidence() -> None: ) -def test_profile_rejects_unregistered_provider_or_api_aliases() -> None: - for changes in ({"provider": "openai"}, {"api": "responses"}): - with pytest.raises(ValidationError, match="provider and API are unsupported"): - ProjectModelProfileSpec.model_validate(profile_spec().model_dump() | changes) +def test_profile_requires_finite_canonical_run_and_tool_envelopes() -> None: + multi = ProjectModelProfileSpec.model_validate( + profile_spec().model_dump() + | { + "maximum_calls": 3, + "maximum_total_request_bytes": 30_000, + "maximum_total_response_bytes": 60_000, + "maximum_total_input_tokens": 300, + "maximum_total_output_tokens": 150, + "allowed_tools": ("execution_receipt", "read_material"), + "maximum_tool_calls": 8, + "maximum_tool_output_bytes": 100_000, + } + ) + assert multi.maximum_reservation_nanodollars == 300 * 11 + 150 * 29 + with pytest.raises(ValidationError, match="canonical"): + ProjectModelProfileSpec.model_validate( + multi.model_dump() | {"allowed_tools": ("read_material", "execution_receipt")} + ) + with pytest.raises(ValidationError, match="run totals"): + ProjectModelProfileSpec.model_validate( + multi.model_dump() | {"maximum_total_input_tokens": 301} + ) + + +def test_budget_requires_explicit_reconciled_remaining_allowance() -> None: + with pytest.raises(ValidationError, match="reconciled authorization"): + ProjectModelBudgetConfigurationSpec( + project_id=new_record_id(), + configuration_version=1, + authorized_total_nanodollars=10_000_000_000, + externally_reconciled_prior_spend_nanodollars=2_000_000_000, + lifetime_limit_nanodollars=9_000_000_000, + ) + + +def test_profile_is_provider_neutral_but_rejects_unbounded_identifiers() -> None: + for changes in ( + {"provider": "openai", "api": "responses"}, + {"provider": "openrouter", "api": "chat_completions"}, + ): + assert ProjectModelProfileSpec.model_validate( + profile_spec().model_dump() | changes + ).provider == changes["provider"] + with pytest.raises(ValidationError): + ProjectModelProfileSpec.model_validate( + profile_spec().model_dump() | {"provider": "https://bad host"} + ) def test_settlement_prices_cached_input_separately_and_reasoning_once() -> None: @@ -134,7 +186,7 @@ def test_stable_slot_identity_has_no_lease_or_evaluation_generation() -> None: submission_id=new_record_id(), submission_version=2, requirement_id="model_output_quality", - registry_entry_digest=spec.registry_entry_digest, + registry_entry_digest=sha("registry"), configuration_sha256=sha("configuration"), profile_id=new_record_id(), profile_version=spec.profile_version, @@ -156,6 +208,26 @@ def test_stable_slot_identity_has_no_lease_or_evaluation_generation() -> None: } +def test_setup_slot_binds_attempt_generation_runtime_profile_and_call_order() -> None: + key = SetupModelUsageSlotKey( + compilation_attempt_id=new_record_id(), + project_id=new_record_id(), + guide_id=new_record_id(), + source_snapshot_id=new_record_id(), + setup_run_id=new_record_id(), + setup_generation=4, + runtime_configuration_hash=sha("setup-runtime"), + profile_id=new_record_id(), + profile_version=2, + profile_digest=sha("setup-profile"), + phase="review", + call_index=3, + ) + + assert key.digest == canonical_json_hash(key.model_dump(mode="json")) + assert "checker_run_id" not in type(key).model_fields + + def test_profile_settings_reject_prompt_or_unapproved_provider_parameters() -> None: with pytest.raises(ValidationError): ModelGenerationSettings.model_validate( diff --git a/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py index 591ae2d71..de7124053 100644 --- a/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py @@ -15,13 +15,13 @@ from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id -from app.modules.authorization.domain.checker_models import ( +from app.modules.authorization.domain.model_usage import ( BUDGET_CONFIGURE, PROFILE_PROVISION, project_model_admin_resource, ) from app.modules.authorization.domain.resource_digest import authorization_resource_digest -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( KnownNoEffectProof, ModelActionEvidence, ModelBudgetProvisioningFacts, @@ -37,13 +37,14 @@ ModelProfileProvisioningFacts, ProjectModelBudgetConfigurationSpec, ProjectModelProfileSpec, + SetupModelUsageSlotKey, model_action_digest, model_retry_terminal_digest, ) -from app.modules.checkers.model_calls.models import CheckerModelBudgetAccount -from app.modules.checkers.model_calls.profiles import ModelProfileReader -from app.modules.checkers.model_calls.repository import ModelCallRepository -from app.modules.checkers.model_calls.service import ModelCallService +from app.modules.model_usage.models import ModelUsageBudgetAccount, ModelUsageEffect +from app.modules.model_usage.profiles import ModelProfileReader +from app.modules.model_usage.service import ModelCallService +from app.modules.model_usage.setup import SetupModelUsageService def sha(label: str) -> str: @@ -75,7 +76,7 @@ async def model_database(postgres_database_url: str): registry_entry_id = new_record_id() registry_entry_digest = sha("registry") migration = runpy.run_path( - str(Path(__file__).resolve().parents[2] / "alembic/versions/0060_checker_model_runtime.py") + str(Path(__file__).resolve().parents[2] / "alembic/versions/0060_model_usage.py") ) def isolated(statement: str) -> str: @@ -104,6 +105,10 @@ def isolated(statement: str) -> str: "create table artifact_contents(id uuid primary key)", "create table artifact_put_attempts(id uuid primary key)", "create table artifact_verification_receipts(id uuid primary key)", + "create table project_guide_compilation_attempts(" + "id uuid primary key,project_id uuid not null,guide_id uuid not null," + "source_snapshot_id uuid not null,setup_run_id uuid not null," + "setup_generation bigint not null,runtime_configuration_hash text,status text)", ): await connection.execute(text(statement)) for statement in migration["_TABLES"]: @@ -240,6 +245,51 @@ async def authorize_complete(self, effect, response_evidence, terminal_digest): }, ) + async def authorize_setup_prepare(self, slot, request_sha256): + return self._decision( + "setup_prepare", + {"slot": slot.model_dump(mode="json"), "request_sha256": request_sha256}, + ) + + async def authorize_setup_dispatch(self, prepared, request_evidence): + return self._decision( + "setup_dispatch", + { + "prepared": prepared.model_dump(mode="json"), + "request_evidence": request_evidence.model_dump(mode="json"), + }, + ) + + async def authorize_setup_observe(self, effect, provider_response_id, observation_digest): + return self._decision( + "setup_observe", + { + "effect": effect.model_dump(mode="json"), + "provider_response_id": provider_response_id, + "observation_digest": observation_digest, + }, + ) + + async def authorize_setup_complete(self, effect, response_evidence, terminal_digest): + return self._decision( + "setup_complete", + { + "effect": effect.model_dump(mode="json"), + "response_evidence": ( + response_evidence.model_dump(mode="json") + if response_evidence is not None + else None + ), + "terminal_digest": terminal_digest, + }, + ) + + async def require_setup_request(self, prepared, evidence): + self.request_checks += 1 + + async def require_setup_response(self, effect_id, evidence): + self.response_checks += 1 + async def require_current(self, facts, slot): self.current_checks += 1 self.events.append("current") @@ -354,9 +404,7 @@ async def authorize_observe(self, effect, provider_response_id, observation_dige ) async def authorize_child(self, project_id, parent_attempt_id, terminal_digest): - evidence = await self._ports.authorize_child( - project_id, parent_attempt_id, terminal_digest - ) + evidence = await self._ports.authorize_child(project_id, parent_attempt_id, terminal_digest) return await self._authority( evidence, action="project.model_call.recovery_child.authorize", @@ -364,15 +412,79 @@ async def authorize_child(self, project_id, parent_attempt_id, terminal_digest): ) async def authorize_complete(self, effect, response_evidence, terminal_digest): - evidence = await self._ports.authorize_complete( - effect, response_evidence, terminal_digest - ) + evidence = await self._ports.authorize_complete(effect, response_evidence, terminal_digest) return await self._authority( evidence, action="checker.model_call.complete", permission="checker.model_call.complete", ) + async def authorize_setup_prepare(self, slot, request_sha256): + evidence = await self._ports.authorize_setup_prepare(slot, request_sha256) + return await self._authority( + evidence, + action="project.guide_compilation.model.prepare", + permission="project.guide_compilation.model.prepare", + ) + + async def authorize_setup_dispatch(self, prepared, request_evidence): + evidence = await self._ports.authorize_setup_dispatch(prepared, request_evidence) + return await self._authority( + evidence, + action="project.guide_compilation.model.dispatch", + permission="project.guide_compilation.model.dispatch", + ) + + async def authorize_setup_observe(self, effect, provider_response_id, observation_digest): + evidence = await self._ports.authorize_setup_observe( + effect, provider_response_id, observation_digest + ) + return await self._authority( + evidence, + action="project.guide_compilation.model.observe", + permission="project.guide_compilation.model.observe", + ) + + async def authorize_setup_complete(self, effect, response_evidence, terminal_digest): + evidence = await self._ports.authorize_setup_complete( + effect, response_evidence, terminal_digest + ) + return await self._authority( + evidence, + action="project.guide_compilation.model.complete", + permission="project.guide_compilation.model.complete", + ) + + async def require_setup_request(self, prepared, evidence): + await self._ports.require_setup_request(prepared, evidence) + await self._artifact(evidence) + + async def require_setup_response(self, effect_id, evidence): + await self._ports.require_setup_response(effect_id, evidence) + await self._artifact(evidence) + + async def require_setup_current(self, slot): + attempt_id = await self._session.scalar( + text( + "select id from project_guide_compilation_attempts " + "where id=:id and project_id=:project and guide_id=:guide " + "and source_snapshot_id=:snapshot and setup_run_id=:run " + "and setup_generation=:generation and runtime_configuration_hash=:runtime " + "and status='compilation_reserved' for update" + ), + { + "id": slot.compilation_attempt_id, + "project": slot.project_id, + "guide": slot.guide_id, + "snapshot": slot.source_snapshot_id, + "run": slot.setup_run_id, + "generation": slot.setup_generation, + "runtime": slot.runtime_configuration_hash, + }, + ) + if attempt_id is None: + raise ModelCallUnavailable("setup_model_owner_unavailable") + async def require_current(self, facts, slot): await self._ports.require_current(facts, slot) @@ -410,6 +522,60 @@ def service(session: AsyncSession, ports: BoundaryPorts) -> ModelCallService: ) +def setup_service(session: AsyncSession, ports: BoundaryPorts) -> SetupModelUsageService: + boundaries = DatabaseBoundaryPorts(session, ports) + return SetupModelUsageService( + session, + authority=boundaries, + evidence=boundaries, + owner=boundaries, + ) + + +async def setup_slot_key( + facts: DatabaseFacts, + profile, + *, + call_index: int = 0, +) -> SetupModelUsageSlotKey: + attempt_id = new_record_id() + guide_id = new_record_id() + snapshot_id = new_record_id() + setup_run_id = new_record_id() + runtime_hash = sha("setup-runtime") + async with facts.engine.begin() as connection: + await connection.execute( + text( + "insert into project_guide_compilation_attempts(" + "id,project_id,guide_id,source_snapshot_id,setup_run_id," + "setup_generation,runtime_configuration_hash,status) values(" + ":id,:project,:guide,:snapshot,:run,1,:runtime,'compilation_reserved')" + ), + { + "id": attempt_id, + "project": facts.project_id, + "guide": guide_id, + "snapshot": snapshot_id, + "run": setup_run_id, + "runtime": runtime_hash, + }, + ) + return SetupModelUsageSlotKey( + compilation_attempt_id=attempt_id, + project_id=facts.project_id, + guide_id=guide_id, + source_snapshot_id=snapshot_id, + setup_run_id=setup_run_id, + setup_generation=1, + runtime_configuration_hash=runtime_hash, + profile_id=profile.profile_id, + profile_version=profile.spec.profile_version, + profile_digest=profile.profile_digest, + phase="draft", + call_index=call_index, + ) + + async def provision( facts: DatabaseFacts, ports: BoundaryPorts, @@ -421,8 +587,6 @@ async def provision( profile_spec = ProjectModelProfileSpec( project_id=facts.project_id, profile_version=1, - registry_entry_id=facts.registry_entry_id, - registry_entry_digest=facts.registry_entry_digest, provider="openai_responses", api="responses.v1", model_snapshot="gpt-example-2026-10-01", @@ -432,6 +596,14 @@ async def provision( maximum_response_bytes=30_000, maximum_input_tokens=100, maximum_output_tokens=50, + maximum_calls=1, + maximum_total_request_bytes=10_000, + maximum_total_response_bytes=30_000, + maximum_total_input_tokens=100, + maximum_total_output_tokens=50, + allowed_tools=(), + maximum_tool_calls=0, + maximum_tool_output_bytes=0, deadline_ms=30_000, pricing=ModelPricing( pricing_policy_id=new_record_id(), @@ -453,6 +625,8 @@ async def provision( budget_spec = ProjectModelBudgetConfigurationSpec( project_id=facts.project_id, configuration_version=1, + authorized_total_nanodollars=10_000_000_000, + externally_reconciled_prior_spend_nanodollars=0, lifetime_limit_nanodollars=limit, ) budget_facts = ModelBudgetProvisioningFacts( @@ -495,58 +669,16 @@ async def test_profile_publication_rejects_substituted_authority_digest( ) -async def test_profile_publication_requires_exact_post_submit_registry( +async def test_profile_publication_is_not_owned_by_a_checker_registry_entry( model_database: DatabaseFacts, ) -> None: - with pytest.raises(ModelCallConflict, match="model_profile_registry_conflict"): - await provision( - model_database, - BoundaryPorts(), - limit=10_000, - profile_changes={"registry_entry_digest": sha("substituted-registry")}, - ) - - pre_entry_id = new_record_id() - pre_digest = sha("pre-registry") async with model_database.engine.begin() as connection: await connection.execute( - text( - "insert into external_checker_registry_entries(id,entry_digest,phase) " - "values (:id,:digest,'pre_submit')" - ), - {"id": pre_entry_id, "digest": pre_digest}, - ) - with pytest.raises(ModelCallConflict, match="model_profile_registry_conflict"): - await provision( - model_database, - BoundaryPorts(), - limit=10_000, - profile_changes={ - "registry_entry_id": pre_entry_id, - "registry_entry_digest": pre_digest, - }, - ) - - -async def test_database_profile_guard_rechecks_registry_digest( - model_database: DatabaseFacts, - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def substituted_registry(*_args, **_kwargs): - return True - - monkeypatch.setattr( - ModelCallRepository, - "is_exact_post_submit_registry_entry", - substituted_registry, - ) - with pytest.raises(DBAPIError, match="profile registry binding"): - await provision( - model_database, - BoundaryPorts(), - limit=10_000, - profile_changes={"registry_entry_digest": sha("substituted-registry")}, + text("update external_checker_registry_entries set phase='pre_submit' where id=:id"), + {"id": model_database.registry_entry_id}, ) + profile = await provision(model_database, BoundaryPorts(), limit=10_000) + assert profile.spec.project_id == model_database.project_id async def test_database_guards_recompute_profile_and_budget_digests( @@ -558,14 +690,17 @@ async def test_database_guards_recompute_profile_and_budget_digests( async with session.begin(): await session.execute( text( - "insert into checker_model_profiles select " - ":id,project_id,2,:digest,registry_entry_id,registry_entry_digest," - "provider,api,model_snapshot,generation_settings,maximum_request_bytes," + "insert into model_usage_profiles select " + ":id,project_id,2,:digest,provider,api,model_snapshot," + "generation_settings,maximum_request_bytes," "maximum_output_bytes,maximum_response_bytes,maximum_input_tokens," - "maximum_output_tokens,deadline_ms,call_slots,pricing_policy_id," + "maximum_output_tokens,maximum_calls,maximum_total_request_bytes," + "maximum_total_response_bytes,maximum_total_input_tokens," + "maximum_total_output_tokens,allowed_tools,maximum_tool_calls," + "maximum_tool_output_bytes,deadline_ms,pricing_policy_id," "pricing_policy_version,pricing_policy_digest,uncached_input_rate," "cached_input_rate,output_rate,:operation,actor_profile_id,:authority," - "clock_timestamp() from checker_model_profiles where profile_version=1" + "clock_timestamp() from model_usage_profiles where profile_version=1" ), { "id": new_record_id(), @@ -580,10 +715,12 @@ async def test_database_guards_recompute_profile_and_budget_digests( async with session.begin(): await session.execute( text( - "insert into checker_model_budget_configurations select " - ":id,project_id,2,:digest,lifetime_limit_nanodollars,:operation," + "insert into model_usage_budget_configurations select " + ":id,project_id,2,:digest,authorized_total_nanodollars," + "externally_reconciled_prior_spend_nanodollars," + "lifetime_limit_nanodollars,:operation," "actor_profile_id,:authority,clock_timestamp() " - "from checker_model_budget_configurations where configuration_version=1" + "from model_usage_budget_configurations where configuration_version=1" ), { "id": new_record_id(), @@ -636,6 +773,24 @@ def evidence(label: str, content_sha256: str) -> ModelCallEvidence: ) +async def wait_until_blocked_by( + engine: AsyncEngine, + *, + blocked_pid: int, + blocker_pid: int, +) -> None: + """Observe an actual PostgreSQL lock wait instead of inferring final totals.""" + for _ in range(200): + async with engine.connect() as connection: + blockers = await connection.scalar( + text("select pg_blocking_pids(:pid)"), {"pid": blocked_pid} + ) + if blocker_pid in blockers: + return + await asyncio.sleep(0.01) + raise AssertionError(f"backend {blocked_pid} did not block behind backend {blocker_pid}") + + async def prepare_and_reserve( facts: DatabaseFacts, ports: BoundaryPorts, @@ -692,11 +847,345 @@ async def dispatch(item): assert sum(not isinstance(result, BaseException) for result in results) == 1 assert sum(isinstance(result, ModelCallBudgetUnavailable) for result in results) == 1 async with AsyncSession(model_database.engine) as session: - account = await session.get(CheckerModelBudgetAccount, str(model_database.project_id)) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) assert account.lifetime_reserved_nanodollars == reservation assert account.lifetime_settled_nanodollars == 0 +async def test_setup_and_checker_dispatches_share_one_account_and_both_win( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + per_dispatch = 100 * 11 + 50 * 29 + profile = await provision( + model_database, + ports, + limit=per_dispatch * 2, + profile_changes={ + "maximum_calls": 2, + "maximum_total_request_bytes": 20_000, + "maximum_total_response_bytes": 60_000, + "maximum_total_input_tokens": 200, + "maximum_total_output_tokens": 100, + }, + ) + checker_run = execution(model_database) + checker_slot = slot(model_database, profile, "shared_budget_checker") + checker_sha = sha("shared-budget-checker") + checker_evidence = evidence("shared-budget-checker", checker_sha) + setup_key = await setup_slot_key(model_database, profile) + setup_sha = sha("shared-budget-setup") + setup_evidence = evidence("shared-budget-setup", setup_sha) + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + checker_prepared = await service(session, ports).prepare_attempt( + checker_run, + checker_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=checker_sha, + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + setup_prepared = await setup_service(session, ports).prepare(setup_key, setup_sha) + + async def reserve_checker(): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await service(session, ports).reserve_dispatch( + checker_run, checker_prepared, checker_evidence + ) + + async def reserve_setup(): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await setup_service(session, ports).reserve_dispatch( + setup_prepared, setup_evidence + ) + + checker_reserved, setup_reserved = await asyncio.gather(reserve_checker(), reserve_setup()) + assert checker_reserved.reserved_nanodollars == per_dispatch + assert setup_reserved.reserved_nanodollars == per_dispatch + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == per_dispatch * 2 + assert account.lifetime_settled_nanodollars == 0 + + +async def test_cheap_completed_call_reserves_remaining_aggregate_before_next_dispatch( + model_database: DatabaseFacts, +) -> None: + """A cheap first result cannot defer an aggregate-token failure until after I/O.""" + ports = BoundaryPorts() + per_dispatch = 100 * 11 + 50 * 29 + profile = await provision( + model_database, + ports, + limit=per_dispatch * 2, + profile_changes={ + "maximum_calls": 2, + "maximum_total_request_bytes": 20_000, + "maximum_total_response_bytes": 60_000, + "maximum_total_input_tokens": 100, + "maximum_total_output_tokens": 100, + }, + ) + run = execution(model_database) + first_slot = slot(model_database, profile, "aggregate_pre_dispatch") + second_slot = first_slot.model_copy(update={"slot_index": 1}) + first_sha = sha("aggregate-pre-dispatch-first") + second_sha = sha("aggregate-pre-dispatch-second") + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + first_prepared = await owner.prepare_attempt( + run, + first_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=first_sha, + ) + first_dispatched = await owner.reserve_dispatch( + run, + first_prepared, + evidence("aggregate-pre-dispatch-first", first_sha), + ) + await owner.complete( + first_dispatched.effect, + provider_response_id="resp_cheap_cached", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=evidence( + "aggregate-pre-dispatch-response", + sha("aggregate-pre-dispatch-response"), + ), + usage=ModelCallUsage( + input_tokens=10, + cached_input_tokens=10, + output_tokens=0, + reasoning_tokens=0, + ), + ) + second_prepared = await owner.prepare_attempt( + run, + second_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=second_sha, + ) + with pytest.raises(ModelCallBudgetUnavailable, match="run_envelope"): + await owner.reserve_dispatch( + run, + second_prepared, + evidence("aggregate-pre-dispatch-second", second_sha), + ) + + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + second = await session.get(ModelUsageEffect, str(second_prepared.attempt_id)) + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == 10 * 2 + assert second is not None and second.state == "prepared" + + +async def test_setup_draft_review_correction_settle_three_shared_calls( + model_database: DatabaseFacts, +) -> None: + """One exact setup attempt can run a finite three-call owner-controlled loop.""" + ports = BoundaryPorts() + per_dispatch = 100 * 11 + 50 * 29 + profile = await provision( + model_database, + ports, + limit=per_dispatch * 3, + profile_changes={ + "maximum_calls": 3, + "maximum_total_request_bytes": 30_000, + "maximum_total_response_bytes": 90_000, + "maximum_total_input_tokens": 300, + "maximum_total_output_tokens": 150, + }, + ) + base = await setup_slot_key(model_database, profile) + settled = 0 + for index, phase in enumerate(("draft", "review", "correction")): + key = base.model_copy(update={"phase": phase, "call_index": index}) + request_sha = sha(f"setup-loop-{phase}-request") + response_sha = sha(f"setup-loop-{phase}-response") + usage = ModelCallUsage( + input_tokens=10 + index, + cached_input_tokens=index, + output_tokens=5 + index, + reasoning_tokens=index, + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = setup_service(session, ports) + prepared = await owner.prepare(key, request_sha) + dispatched = await owner.reserve_dispatch( + prepared, evidence(f"setup-loop-{phase}-request", request_sha) + ) + completed = await owner.complete( + dispatched.effect, + provider_response_id=f"resp_setup_{phase}", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=evidence(f"setup-loop-{phase}-response", response_sha), + usage=usage, + ) + assert completed.effect.slot_key == key + assert completed.state == "completed" + assert completed.replayed is False + settled += completed.settled_nanodollars + + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + states = ( + ( + await session.execute( + text( + "select effect.state from model_usage_effects effect " + "join setup_model_usage_slots slot on slot.id=effect.setup_slot_id " + "where slot.compilation_attempt_id=:attempt order by slot.call_index" + ), + {"attempt": base.compilation_attempt_id}, + ) + ) + .scalars() + .all() + ) + assert states == ["completed", "completed", "completed"] + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == settled + + +async def test_uncertain_setup_effect_replay_never_reauthorizes_dispatch( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation) + key = await setup_slot_key(model_database, profile) + request_sha = sha("setup-uncertain-request") + request_evidence = evidence("setup-uncertain-request", request_sha) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = setup_service(session, ports) + prepared = await owner.prepare(key, request_sha) + dispatched = await owner.reserve_dispatch(prepared, request_evidence) + observed = await owner.mark_uncertain( + dispatched.effect, + sha("setup-uncertain-observation"), + ) + assert observed.state == "uncertain" + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = setup_service(session, ports) + replayed = await owner.prepare(key, request_sha) + with pytest.raises(ModelCallConflict, match="not_dispatchable"): + await owner.reserve_dispatch(replayed, request_evidence) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == reservation + assert account.lifetime_settled_nanodollars == 0 + + +async def test_setup_and_checker_dispatches_share_one_account_cap( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + per_dispatch = 100 * 11 + 50 * 29 + profile = await provision( + model_database, + ports, + limit=per_dispatch, + profile_changes={ + "maximum_calls": 2, + "maximum_total_request_bytes": 20_000, + "maximum_total_response_bytes": 60_000, + "maximum_total_input_tokens": 200, + "maximum_total_output_tokens": 100, + }, + ) + checker_run = execution(model_database) + checker_slot = slot(model_database, profile, "shared_cap_checker") + checker_sha = sha("shared-cap-checker") + checker_evidence = evidence("shared-cap-checker", checker_sha) + setup_key = await setup_slot_key(model_database, profile) + setup_sha = sha("shared-cap-setup") + setup_evidence = evidence("shared-cap-setup", setup_sha) + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + checker_prepared = await service(session, ports).prepare_attempt( + checker_run, + checker_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=checker_sha, + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + setup_prepared = await setup_service(session, ports).prepare(setup_key, setup_sha) + + async def reserve_checker(): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await service(session, ports).reserve_dispatch( + checker_run, checker_prepared, checker_evidence + ) + + async def reserve_setup(): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await setup_service(session, ports).reserve_dispatch( + setup_prepared, setup_evidence + ) + + results = await asyncio.gather(reserve_checker(), reserve_setup(), return_exceptions=True) + assert sum(not isinstance(result, BaseException) for result in results) == 1 + assert sum(isinstance(result, ModelCallBudgetUnavailable) for result in results) == 1 + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == per_dispatch + assert account.lifetime_settled_nanodollars == 0 + + +async def test_setup_reservation_rollback_releases_shared_account( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + per_dispatch = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=per_dispatch) + setup_key = await setup_slot_key(model_database, profile) + setup_sha = sha("rollback-shared-setup") + setup_evidence = evidence("rollback-shared-setup", setup_sha) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + setup_prepared = await setup_service(session, ports).prepare(setup_key, setup_sha) + with pytest.raises(RuntimeError, match="force setup rollback"): + async with session.begin(): + await setup_service(session, ports).reserve_dispatch(setup_prepared, setup_evidence) + raise RuntimeError("force setup rollback") + + checker_run = execution(model_database) + checker_slot = slot(model_database, profile, "after_setup_rollback") + checker_sha = sha("after-setup-rollback") + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + checker_prepared = await service(session, ports).prepare_attempt( + checker_run, + checker_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=checker_sha, + ) + reserved = await service(session, ports).reserve_dispatch( + checker_run, + checker_prepared, + evidence("after-setup-rollback", checker_sha), + ) + assert reserved.reserved_nanodollars == per_dispatch + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == per_dispatch + assert account.lifetime_settled_nanodollars == 0 + + async def test_fresh_execution_authority_precedes_currentness_and_owner_locks( model_database: DatabaseFacts, ) -> None: @@ -801,7 +1290,7 @@ async def test_reservation_rollback_and_completion_settle_exactly_once( response_evidence=response_art, usage=usage, ) - account = await session.get(CheckerModelBudgetAccount, str(model_database.project_id)) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) assert account.lifetime_reserved_nanodollars == 0 assert account.lifetime_settled_nanodollars == completed.settled_nanodollars assert replay.replayed @@ -818,6 +1307,155 @@ async def test_reservation_rollback_and_completion_settle_exactly_once( assert ports.events == ["recover", "current"] +@pytest.mark.parametrize("terminal_kind", ["complete", "known_no_effect"]) +async def test_terminal_holds_envelope_before_other_slot_and_account( + model_database: DatabaseFacts, + terminal_kind: str, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision( + model_database, + ports, + limit=reservation * 2, + profile_changes={ + "maximum_calls": 2, + "maximum_total_request_bytes": 20_000, + "maximum_total_response_bytes": 60_000, + "maximum_total_input_tokens": 200, + "maximum_total_output_tokens": 100, + }, + ) + run = execution(model_database) + first_slot = slot(model_database, profile, f"terminal_order_{terminal_kind}") + second_slot = first_slot.model_copy(update={"slot_index": 1}) + first_sha = sha(f"terminal-order-{terminal_kind}-first") + second_sha = sha(f"terminal-order-{terminal_kind}-second") + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + first_prepared = await owner.prepare_attempt( + run, + first_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=first_sha, + ) + first_dispatched = await owner.reserve_dispatch( + run, + first_prepared, + evidence("terminal-order-first", first_sha), + ) + second_prepared = await owner.prepare_attempt( + run, + second_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=second_sha, + ) + + holder = AsyncSession(model_database.engine, expire_on_commit=False) + terminal = AsyncSession(model_database.engine, expire_on_commit=False) + contender = AsyncSession(model_database.engine, expire_on_commit=False) + terminal_task = None + contender_task = None + try: + await holder.begin() + await terminal.begin() + await contender.begin() + holder_pid = await holder.scalar(text("select pg_backend_pid()")) + terminal_pid = await terminal.scalar(text("select pg_backend_pid()")) + contender_pid = await contender.scalar(text("select pg_backend_pid()")) + await holder.execute( + text( + "select project_id from model_usage_budget_accounts " + "where project_id=:project_id for update" + ), + {"project_id": model_database.project_id}, + ) + + if terminal_kind == "complete": + terminal_task = asyncio.create_task( + service(terminal, ports).complete( + first_dispatched.effect, + provider_response_id="resp_lock_order", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=evidence( + "terminal-order-response", sha("terminal-order-response") + ), + usage=ModelCallUsage( + input_tokens=40, + cached_input_tokens=10, + output_tokens=12, + reasoning_tokens=7, + ), + ) + ) + else: + terminal_task = asyncio.create_task( + service(terminal, ports).mark_known_no_effect( + first_dispatched.effect, + KnownNoEffectProof( + proof_id=new_record_id(), + proof_digest=sha("terminal-order-no-effect"), + observed_at=datetime.now(UTC), + ), + ) + ) + await wait_until_blocked_by( + model_database.engine, + blocked_pid=terminal_pid, + blocker_pid=holder_pid, + ) + + contender_task = asyncio.create_task( + service(contender, ports).reserve_dispatch( + run, + second_prepared, + evidence("terminal-order-second", second_sha), + ) + ) + await wait_until_blocked_by( + model_database.engine, + blocked_pid=contender_pid, + blocker_pid=terminal_pid, + ) + + # The contender is waiting at the run envelope; it owns neither child row. + async with AsyncSession(model_database.engine) as observer: + async with observer.begin(): + await observer.execute( + text("select id from checker_model_usage_slots where id=:id for update nowait"), + {"id": second_prepared.slot_id}, + ) + await observer.execute( + text("select id from model_usage_effects where id=:id for update nowait"), + {"id": second_prepared.attempt_id}, + ) + + await holder.commit() + await terminal_task + await terminal.commit() + await contender_task + await contender.commit() + finally: + for task in (terminal_task, contender_task): + if task is not None and not task.done(): + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + for session in (holder, terminal, contender): + if session.in_transaction(): + await session.rollback() + await session.close() + + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == reservation + if terminal_kind == "complete": + assert account.lifetime_settled_nanodollars == 30 * 11 + 10 * 2 + 12 * 29 + else: + assert account.lifetime_settled_nanodollars == 0 + + async def test_uncertain_holds_budget_and_only_proven_no_effect_allows_child( model_database: DatabaseFacts, ) -> None: @@ -851,7 +1489,7 @@ async def test_uncertain_holds_budget_and_only_proven_no_effect_allows_child( terminal_digest=uncertainty, request_sha256=prepared.request_sha256, ) - account = await session.get(CheckerModelBudgetAccount, str(model_database.project_id)) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) assert account.lifetime_reserved_nanodollars == reservation # A distinct call proves the only zero-cost child path: authenticated no effect. @@ -884,7 +1522,7 @@ async def test_uncertain_holds_budget_and_only_proven_no_effect_allows_child( terminal_digest=sha("substituted-terminal"), request_sha256=prepared2.request_sha256, ) - account = await session.get(CheckerModelBudgetAccount, str(model_database.project_id)) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) assert account.lifetime_reserved_nanodollars == reservation @@ -893,13 +1531,15 @@ async def test_budget_adjustment_preserves_reserved_and_settles_old_snapshot( ) -> None: ports = BoundaryPorts() reservation = 100 * 11 + 50 * 29 - profile = await provision(model_database, ports, limit=reservation * 2) - run, paid_slot, prepared, dispatched, _ = await prepare_and_reserve( + profile = await provision(model_database, ports, limit=reservation) + _run, _paid_slot, _prepared, dispatched, _ = await prepare_and_reserve( model_database, ports, profile, "model_budget_adjustment" ) adjusted_spec = ProjectModelBudgetConfigurationSpec( project_id=model_database.project_id, configuration_version=2, + authorized_total_nanodollars=20_000_000_000, + externally_reconciled_prior_spend_nanodollars=123, lifetime_limit_nanodollars=reservation * 3, ) adjusted_facts = ModelBudgetProvisioningFacts( @@ -914,10 +1554,18 @@ async def test_budget_adjustment_preserves_reserved_and_settles_old_snapshot( assert adjusted.lifetime_reserved_nanodollars == reservation assert adjusted.lifetime_settled_nanodollars == 0 + # The explicitly increased ceiling admits another dispatch without resetting + # the first reservation or counting ledger usage as external prior spend. + _second = await prepare_and_reserve( + model_database, ports, profile, "model_budget_adjustment_second" + ) + too_low_spec = ProjectModelBudgetConfigurationSpec( project_id=model_database.project_id, configuration_version=3, - lifetime_limit_nanodollars=reservation - 1, + authorized_total_nanodollars=20_000_000_000, + externally_reconciled_prior_spend_nanodollars=123, + lifetime_limit_nanodollars=reservation * 2 - 1, ) too_low_facts = ModelBudgetProvisioningFacts( operation_id=uuid4(), @@ -945,11 +1593,56 @@ async def test_budget_adjustment_preserves_reserved_and_settles_old_snapshot( response_evidence=evidence("response", sha("budget-adjustment-response")), usage=usage, ) - account = await session.get(CheckerModelBudgetAccount, str(model_database.project_id)) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) assert account.current_configuration_id == str(adjusted.configuration_id) - assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_reserved_nanodollars == reservation + assert account.lifetime_settled_nanodollars == completed.settled_nanodollars + + next_spec = ProjectModelBudgetConfigurationSpec( + project_id=model_database.project_id, + configuration_version=3, + authorized_total_nanodollars=30_000_000_000, + externally_reconciled_prior_spend_nanodollars=456, + lifetime_limit_nanodollars=reservation * 4, + ) + next_facts = ModelBudgetProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=next_spec, + configuration_digest=canonical_json_hash(next_spec.model_dump(mode="json")), + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + next_budget = await service(session, ports).provision_budget(next_facts) + replayed_old = await service(session, ports).provision_budget(adjusted_facts) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert replayed_old.configuration_id == adjusted.configuration_id + assert account.current_configuration_id == str(next_budget.configuration_id) + assert account.lifetime_reserved_nanodollars == reservation assert account.lifetime_settled_nanodollars == completed.settled_nanodollars + for authorized_total, reconciled_prior in ( + (29_000_000_000, 456), + (30_000_000_000, 455), + ): + shrinking_spec = ProjectModelBudgetConfigurationSpec( + project_id=model_database.project_id, + configuration_version=4, + authorized_total_nanodollars=authorized_total, + externally_reconciled_prior_spend_nanodollars=reconciled_prior, + lifetime_limit_nanodollars=reservation * 4, + ) + shrinking_facts = ModelBudgetProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=shrinking_spec, + configuration_digest=canonical_json_hash(shrinking_spec.model_dump(mode="json")), + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + with pytest.raises(ModelCallConflict, match="reconciliation_conflict"): + async with session.begin(): + await service(session, ports).provision_budget(shrinking_facts) + async def test_known_failed_settles_once_and_allows_authorized_child( model_database: DatabaseFacts, @@ -993,7 +1686,7 @@ async def test_known_failed_settles_once_and_allows_authorized_child( request_sha256=prepared.request_sha256, ) assert child.attempt_number == 2 - account = await session.get(CheckerModelBudgetAccount, str(model_database.project_id)) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) assert account.lifetime_reserved_nanodollars == 0 assert account.lifetime_settled_nanodollars == failed.settled_nanodollars @@ -1013,7 +1706,7 @@ async def test_database_guards_reject_ledger_mutation_and_counter_substitution( async with session.begin(): await session.execute( text( - "update checker_model_call_slots set first_request_sha256=:digest " + "update checker_model_usage_slots set first_request_sha256=:digest " "where id=:slot_id" ), {"digest": sha("substituted"), "slot_id": dispatched.slot_id}, @@ -1024,7 +1717,7 @@ async def test_database_guards_reject_ledger_mutation_and_counter_substitution( async with session.begin(): await session.execute( text( - "update checker_model_budget_accounts " + "update model_usage_budget_accounts " "set lifetime_reserved_nanodollars=lifetime_reserved_nanodollars-1 " "where project_id=:project_id" ), @@ -1032,7 +1725,7 @@ async def test_database_guards_reject_ledger_mutation_and_counter_substitution( ) async with AsyncSession(model_database.engine) as session: - account = await session.get(CheckerModelBudgetAccount, str(model_database.project_id)) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) assert account.lifetime_reserved_nanodollars == reservation assert account.lifetime_settled_nanodollars == 0 @@ -1062,7 +1755,7 @@ async def test_database_ancestry_prevents_receipt_and_artifact_deletion( await session.execute(text(statement), {"id": identifier}) -async def test_profile_reader_requires_exact_project_registry_and_selector( +async def test_profile_reader_requires_exact_project_and_selector( model_database: DatabaseFacts, ) -> None: ports = BoundaryPorts() @@ -1074,21 +1767,15 @@ async def test_profile_reader_requires_exact_project_registry_and_selector( await reader.read_exact( profile.selector, project_id=model_database.project_id, - registry_entry_id=model_database.registry_entry_id, - registry_entry_digest=model_database.registry_entry_digest, ) == profile ) - for changes in ( - {"project_id": new_record_id()}, - {"registry_entry_id": new_record_id()}, - {"registry_entry_digest": sha("substituted-registry")}, - ): - scope = { - "project_id": model_database.project_id, - "registry_entry_id": model_database.registry_entry_id, - "registry_entry_digest": model_database.registry_entry_digest, - } - scope.update(changes) - with pytest.raises(ModelCallUnavailable, match="model_profile_unavailable"): - await reader.read_exact(profile.selector, **scope) + with pytest.raises(ModelCallUnavailable, match="model_profile_unavailable"): + await reader.read_exact(profile.selector, project_id=new_record_id()) + with pytest.raises(ModelCallUnavailable, match="model_profile_unavailable"): + await reader.read_exact( + profile.selector.model_copy( + update={"profile_digest": sha("substituted-profile")} + ), + project_id=model_database.project_id, + ) diff --git a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py index b3bab38cf..ccb47c63c 100644 --- a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py @@ -15,13 +15,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.identifiers import new_record_id -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelActionEvidence, ModelCallExecutionFacts, ModelPaidSlotKey, model_action_digest, ) -from app.modules.checkers.model_calls.grants import ModelInvocationGrants +from app.modules.model_usage.grants import ModelInvocationGrants from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( BoundaryPorts, DatabaseFacts, @@ -124,28 +124,12 @@ async def grant_database(ledger_database_fixture: DatabaseFacts) -> GrantDatabas async with ledger_database.engine.connect() as connection: schema = str(await connection.scalar(text("select current_schema()"))) migration = runpy.run_path( - str(Path(__file__).resolve().parents[2] / "alembic/versions/0060_checker_model_runtime.py") + str(Path(__file__).resolve().parents[2] / "alembic/versions/0060_model_usage.py") ) capture = _CaptureOperations() migration["_upgrade_invocation_grants"].__globals__["op"] = capture migration["_upgrade_invocation_grants"]() async with ledger_database.engine.begin() as connection: - await connection.execute( - text( - "alter table external_checker_registry_entries " - "add column entry_digest text, add column phase text" - ) - ) - await connection.execute( - text( - "update external_checker_registry_entries set entry_digest=:digest," - "phase='post_submit' where id=:id" - ), - { - "digest": ledger_database.registry_entry_digest, - "id": ledger_database.registry_entry_id, - }, - ) await connection.execute( text( "alter table checker_runs add column project_id uuid, add column task_id uuid," @@ -486,4 +470,4 @@ async def test_grant_trigger_locks_authority_then_task_then_checker_ancestry( insert_branch = definition.split("IF jsonb_typeof", maxsplit=1)[1] assert insert_branch.index("audit_events") < insert_branch.index("workstream_tasks") assert insert_branch.index("workstream_tasks") < insert_branch.index("checker_runs") - assert insert_branch.index("checker_runs") < insert_branch.index("checker_model_profiles") + assert insert_branch.index("checker_runs") < insert_branch.index("model_usage_profiles") diff --git a/backend/tests/checkers_model_calls/test_model_runtime.py b/backend/tests/checkers_model_calls/test_model_runtime.py index 61820cca2..bdd1badd1 100644 --- a/backend/tests/checkers_model_calls/test_model_runtime.py +++ b/backend/tests/checkers_model_calls/test_model_runtime.py @@ -11,7 +11,7 @@ from app.adapters.checkers import model_runtime from app.core.config import Settings -from app.modules.checkers.api.model_calls import ModelCallUnavailable +from app.modules.model_usage.api import ModelCallUnavailable from app.workers import checker_model_proxy from tests.artifact_store_helpers import artifact_admission_limit_settings from tests.checkers_model_calls.test_proxy_orchestration import Harness diff --git a/backend/tests/checkers_model_calls/test_proxy_orchestration.py b/backend/tests/checkers_model_calls/test_proxy_orchestration.py index 908a34ad2..80de4ed7c 100644 --- a/backend/tests/checkers_model_calls/test_proxy_orchestration.py +++ b/backend/tests/checkers_model_calls/test_proxy_orchestration.py @@ -23,7 +23,7 @@ CheckerModelUsage, ) from app.interfaces.checker_model_proxy import ModelProxyRequest -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( AttachedModelCallAttempt, CompletedModelCall, ModelCallBudgetUnavailable, @@ -34,7 +34,7 @@ ProjectModelProfile, ) from app.adapters.checkers.model_proxy import CheckerModelProxy -from tests.checkers_model_calls.test_model_call_contract import profile_spec +from tests.checkers_model_calls.test_model_call_contract import profile_spec, sha def digest(data: bytes) -> str: @@ -74,14 +74,14 @@ def __init__(self): submission_id=self.execution.submission_id, submission_version=1, requirement_id="quality", - registry_entry_digest=spec.registry_entry_digest, + registry_entry_digest=sha("registry"), configuration_sha256=canonical_json_hash({"selector": "quality"}), **self.profile.selector.model_dump(), ) self.facts = SimpleNamespace( execution=self.execution, slot=self.slot, - registry_entry_id=spec.registry_entry_id, + registry_entry_id=new_record_id(), ) self.state, self.request_sha = "prepared", None self.slot_id, self.attempt_id, self.internal_id = (new_record_id() for _ in range(3)) diff --git a/backend/tests/checkers_model_calls/test_public_api.py b/backend/tests/checkers_model_calls/test_public_api.py index 05fa17108..b8b544323 100644 --- a/backend/tests/checkers_model_calls/test_public_api.py +++ b/backend/tests/checkers_model_calls/test_public_api.py @@ -11,7 +11,7 @@ from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id from app.main import create_app -from app.modules.checkers.api.model_calls import ( +from app.modules.model_usage.api import ( ModelCallExecutionFacts, ModelCallConflict, ModelGenerationSettings, @@ -37,8 +37,6 @@ def profile_spec(project_id): return ProjectModelProfileSpec( project_id=project_id, profile_version=1, - registry_entry_id=new_record_id(), - registry_entry_digest=sha("registry"), provider="openai_responses", api="responses.v1", model_snapshot="gpt-example-2026-10-01", @@ -48,6 +46,14 @@ def profile_spec(project_id): maximum_response_bytes=30_000, maximum_input_tokens=100, maximum_output_tokens=50, + maximum_calls=1, + maximum_total_request_bytes=10_000, + maximum_total_response_bytes=30_000, + maximum_total_input_tokens=100, + maximum_total_output_tokens=50, + allowed_tools=(), + maximum_tool_calls=0, + maximum_tool_output_bytes=0, deadline_ms=30_000, pricing=ModelPricing( pricing_policy_id=new_record_id(), @@ -118,6 +124,8 @@ async def prepare_recovery_child(self, slot, **values): budget = ProjectModelBudgetConfigurationSpec( project_id=project_id, configuration_version=1, + authorized_total_nanodollars=10_000_000_000, + externally_reconciled_prior_spend_nanodollars=0, lifetime_limit_nanodollars=10_000_000_000, ) await commands.configure_budget(project_id, budget) @@ -141,15 +149,16 @@ async def prepare_recovery_child(self, slot, **values): submission_id=execution.submission_id, submission_version=1, requirement_id="model_review", - registry_entry_digest=profile.registry_entry_digest, + registry_entry_digest=sha("registry"), configuration_sha256=sha("configuration"), profile_id=new_record_id(), profile_version=1, profile_digest=sha("profile"), ) + registry_entry_id = new_record_id() request = ModelRecoveryChildRequest( slot=slot, - registry_entry_id=profile.registry_entry_id, + registry_entry_id=registry_entry_id, terminal_digest=sha("terminal"), request_sha256=sha("request"), ) @@ -169,7 +178,7 @@ async def prepare_recovery_child(self, slot, **values): "child", slot, { - "registry_entry_id": profile.registry_entry_id, + "registry_entry_id": registry_entry_id, "parent_attempt_id": parent_id, "terminal_digest": request.terminal_digest, "request_sha256": request.request_sha256, @@ -184,10 +193,10 @@ async def _model_control_counts() -> dict[str, int]: async with db_session.get_session_factory()() as session: rows = {} for name in ( - "checker_model_profiles", - "checker_model_budget_configurations", - "checker_model_budget_accounts", - "checker_model_call_attempts", + "model_usage_profiles", + "model_usage_budget_configurations", + "model_usage_budget_accounts", + "model_usage_effects", ): rows[name] = int(await session.scalar(text(f"select count(*) from {name}")) or 0) events = ( @@ -225,16 +234,11 @@ async def test_signed_operator_profile_budget_replay_and_fresh_denial( project_id = await create_project("Model control replay") profile_key = uuid4() - profile = profile_spec(project_id).model_copy( - update={ - "registry_entry_id": UUID(entry["registry_entry_id"]), - "registry_entry_digest": entry["entry_digest"], - } - ) + profile = profile_spec(project_id) profile_headers = admin_access.target.headers | {"Idempotency-Key": str(profile_key)} profile_path = PROFILE_PATH.format(project_id=project_id) malformed_profile = profile.model_dump(mode="json") - malformed_profile["registry_entry_id"] = "not-a-uuid" + malformed_profile["registry_entry_id"] = "profiles-are-not-registry-owned" malformed = await admin_access.signed.client.post( profile_path, headers=admin_access.target.headers, @@ -255,9 +259,7 @@ async def test_signed_operator_profile_budget_replay_and_fresh_denial( assert replayed_profile.status_code == 201, replayed_profile.text assert replayed_profile.json() == first_profile.json() assert first_profile.json()["operation_id"] == str(profile_key) - substituted_profile = profile.model_copy( - update={"registry_entry_digest": sha("substituted-registry")} - ) + substituted_profile = profile.model_copy(update={"model_snapshot": "gpt-substituted"}) substituted = await admin_access.signed.client.post( profile_path, headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, @@ -270,6 +272,8 @@ async def test_signed_operator_profile_budget_replay_and_fresh_denial( budget = ProjectModelBudgetConfigurationSpec( project_id=project_id, configuration_version=1, + authorized_total_nanodollars=10_000_000_000, + externally_reconciled_prior_spend_nanodollars=0, lifetime_limit_nanodollars=10_000_000_000, ) budget_headers = admin_access.target.headers | {"Idempotency-Key": str(budget_key)} @@ -392,10 +396,10 @@ async def test_signed_operator_profile_budget_replay_and_fresh_denial( assert denied_recovery.json()["error"]["code"] == "checker_model_control_not_found" assert await _model_control_counts() == { - "checker_model_profiles": 1, - "checker_model_budget_configurations": 1, - "checker_model_budget_accounts": 1, - "checker_model_call_attempts": 0, + "model_usage_profiles": 1, + "model_usage_budget_configurations": 1, + "model_usage_budget_accounts": 1, + "model_usage_effects": 0, "project.model_profile.provision:true": 2, "project.model_profile.provision:false": 1, "project.model_budget.configure:true": 2, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 54cf51dde..5b0836516 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,7 +23,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "70478073f22b57301b1b8d817ac13722105b77203c3a57384d2e35628e4e4260" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "54167c45f4c103070342da1361f81cb4d5f03390c9c842dee0738136801637bc" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", @@ -57,12 +57,15 @@ "authority_idempotency_records", "checker_policies", "checker_model_invocation_grants", - "checker_model_profiles", - "checker_model_budget_accounts", - "checker_model_budget_configurations", - "checker_model_call_slots", - "checker_model_call_attempts", - "checker_model_call_recoveries", + "model_usage_profiles", + "model_usage_budget_accounts", + "model_usage_budget_configurations", + "checker_model_usage_envelopes", + "checker_model_usage_slots", + "setup_model_usage_envelopes", + "setup_model_usage_slots", + "model_usage_effects", + "checker_model_usage_recoveries", "external_checker_registry_entries", "checker_retry_receipts", "checker_result_findings", @@ -184,12 +187,15 @@ "task_post_submit_routing_requests", "checker_policies", "checker_model_invocation_grants", - "checker_model_profiles", - "checker_model_budget_accounts", - "checker_model_budget_configurations", - "checker_model_call_slots", - "checker_model_call_attempts", - "checker_model_call_recoveries", + "model_usage_profiles", + "model_usage_budget_accounts", + "model_usage_budget_configurations", + "checker_model_usage_envelopes", + "checker_model_usage_slots", + "setup_model_usage_envelopes", + "setup_model_usage_slots", + "model_usage_effects", + "checker_model_usage_recoveries", "admin_role_grants", "audit_events", "authority_control", diff --git a/backend/tests/test_checker_output_custody.py b/backend/tests/test_checker_output_custody.py index a778ae1e6..e246e5d02 100644 --- a/backend/tests/test_checker_output_custody.py +++ b/backend/tests/test_checker_output_custody.py @@ -35,7 +35,7 @@ CheckerOutputBindingRequest, CheckerOutputUnavailable, ) -from app.modules.checkers.model_calls.models import CheckerModelCallAttempt +from app.modules.model_usage.models import ModelUsageEffect from tests.checker_output_custody_helpers import ( byte_stream, output_custody_harness, @@ -466,8 +466,8 @@ async def test_authority_revocation_during_put_prevents_return_and_binding( await asyncio.wait_for(harness.store.put_entered.wait(), timeout=10) async with harness.factory() as probe, probe.begin(): locked_effect = await probe.scalar( - select(CheckerModelCallAttempt) - .where(CheckerModelCallAttempt.id == str(harness.selector.model_call_attempt_id)) + select(ModelUsageEffect) + .where(ModelUsageEffect.id == str(harness.selector.model_call_attempt_id)) .with_for_update(nowait=True) ) locked_attempt = await probe.scalar( diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index fc965025e..ae4b523c4 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -1759,12 +1759,12 @@ another paid effect. | Table | Custody | |---|---| -| `checker_model_profiles` | Immutable project/image/provider/model/bounds/pricing specification and Operator authorization receipt | -| `checker_model_budget_configurations` | Immutable versions of the finite project lifetime ceiling | -| `checker_model_budget_accounts` | Current configuration and serialized lifetime reserved/settled integer USD nanodollars; configuration changes never reset spend | -| `checker_model_call_slots` | Stable project, assignment, Submission/version, requirement, registry digest, configuration hash, exact profile and call-slot identity; excludes retry generation and execution lease | -| `checker_model_call_attempts` | Immutable first request hash, dispatch intent, reservation, provider identity, private ART request/response references, usage and once-only terminal settlement | -| `checker_model_call_recoveries` | Fresh current-run attachment to an existing attempt; cannot authorize a new provider create | +| `model_usage_profiles` | Immutable project/image/provider/model/bounds/pricing specification and Operator authorization receipt | +| `model_usage_budget_configurations` | Immutable versions of the finite project lifetime ceiling | +| `model_usage_budget_accounts` | Current configuration and serialized lifetime reserved/settled integer USD nanodollars; configuration changes never reset spend | +| `checker_model_usage_slots` | Stable project, assignment, Submission/version, requirement, registry digest, configuration hash, exact profile and call-slot identity; excludes retry generation and execution lease | +| `model_usage_effects` | Immutable first request hash, dispatch intent, reservation, provider identity, private ART request/response references, usage and once-only terminal settlement | +| `checker_model_usage_recoveries` | Fresh current-run attachment to an existing attempt; cannot authorize a new provider create | | `checker_model_invocation_grants` | Hashed short-lived capability, exact execution and binding, PostgreSQL expiry and one consumption into the selected attempt/request | An attempt progresses from `prepared` to `dispatching`, with uncertain delivery @@ -1776,7 +1776,7 @@ or incomplete usage never permits another create. Late completion may retain evidence after the originating run ends; fresh current execution is still required to release that output to an image. -Migration `0060_checker_model_runtime` extends existing ART put/receipt custody +Migration `0060_model_usage` extends existing ART put/receipt custody with `model_call_attempt_id` and exact private request/response slots. Transcript bytes stay in ART, never these ledger rows or outbox payloads. Existing historical run-bound ART rows remain retained; new model evidence uses the paid attempt. diff --git a/docs/engineering/repository_architecture_boundaries.md b/docs/engineering/repository_architecture_boundaries.md index 7e6f10be6..c0418a3a2 100644 --- a/docs/engineering/repository_architecture_boundaries.md +++ b/docs/engineering/repository_architecture_boundaries.md @@ -1,8 +1,8 @@ # Modular Monolith Architecture Boundaries -Workstream has nine business modules: `actors`, `authorization`, `projects`, -`tasks`, `artifacts`, `checkers`, `reviews`, `contributions`, and -`compensation`. It has three supporting modules: `audit`, `outbox`, and +Workstream has ten business modules: `actors`, `authorization`, `projects`, +`tasks`, `artifacts`, `checkers`, `model_usage`, `reviews`, `contributions`, +and `compensation`. It has three supporting modules: `audit`, `outbox`, and `api_controls`. The machine-readable canonical registry is `.ci/module-boundaries/registry.v1.json`. From eb3151f77cca8c8e2d6dd11d3720067a3363c3ce Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 18:08:10 +0100 Subject: [PATCH 009/115] refactor(checkers): share private proxy framing --- .../workstream-checker-service/src/main.rs | 1 + .../src/model_relay.rs | 215 ++---------------- .../src/proxy_transport.rs | 203 +++++++++++++++++ 3 files changed, 225 insertions(+), 194 deletions(-) create mode 100644 external_checkers/workstream-checker-service/src/proxy_transport.rs diff --git a/external_checkers/workstream-checker-service/src/main.rs b/external_checkers/workstream-checker-service/src/main.rs index 1cefaaf8f..9970af7af 100644 --- a/external_checkers/workstream-checker-service/src/main.rs +++ b/external_checkers/workstream-checker-service/src/main.rs @@ -1,6 +1,7 @@ //! Trusted long-lived Unix-socket boundary for digest-pinned checker containers. mod model_relay; +mod proxy_transport; use model_relay::{ deserialize_required_model_grant, valid_model_grant, validate_proxy_socket, diff --git a/external_checkers/workstream-checker-service/src/model_relay.rs b/external_checkers/workstream-checker-service/src/model_relay.rs index 40eabdf03..75af693a6 100644 --- a/external_checkers/workstream-checker-service/src/model_relay.rs +++ b/external_checkers/workstream-checker-service/src/model_relay.rs @@ -1,10 +1,10 @@ //! Invocation-local, single-use relay from a checker image to the trusted model proxy. +use crate::proxy_transport::{connect_private, read_frame, write_frame}; use serde::{Deserialize, Serialize}; use std::fmt; use std::fs::{self, DirBuilder, File}; use std::io::{Read, Write}; -use std::os::fd::FromRawFd; use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::{DirBuilderExt, FileTypeExt, MetadataExt, PermissionsExt}; use std::os::unix::net::{UnixListener, UnixStream}; @@ -19,6 +19,8 @@ const MODEL_PROXY_PROTOCOL: &str = "workstream.checker_model_proxy.v1"; const MODEL_FRAME_MAXIMUM: usize = 1024 * 1024; const INVOCATION_DIRECTORY_PREFIX: &str = ".model-"; +pub(crate) use crate::proxy_transport::validate_private_socket as validate_proxy_socket; + #[derive(Clone, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub(crate) struct ModelGrant { @@ -303,37 +305,6 @@ pub(crate) fn valid_model_grant(grant: &ModelGrant, request_digest: &str, phase: .all(|item| item.is_ascii_hexdigit() && !item.is_ascii_uppercase()) } -pub(crate) fn validate_proxy_socket(path: &Path) -> Result<(), String> { - if !path.is_absolute() { - return Err("model proxy socket must be absolute".into()); - } - let parent = path - .parent() - .ok_or_else(|| "model proxy socket parent invalid".to_string())?; - let canonical_parent = - fs::canonicalize(parent).map_err(|_| "model proxy socket parent unavailable")?; - if canonical_parent != parent { - return Err("model proxy socket parent custody invalid".into()); - } - let parent_metadata = - fs::symlink_metadata(parent).map_err(|_| "model proxy socket parent unavailable")?; - if !private_process_directory(&parent_metadata) { - return Err("model proxy socket parent custody invalid".into()); - } - let metadata = fs::symlink_metadata(path).map_err(|_| "model proxy socket unavailable")?; - // SAFETY: effective IDs are read-only process attributes. - let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) }; - if metadata.file_type().is_symlink() - || !metadata.file_type().is_socket() - || metadata.mode() & 0o777 != 0o600 - || metadata.uid() != uid - || metadata.gid() != gid - { - return Err("model proxy socket invalid".into()); - } - Ok(()) -} - pub(crate) fn validate_relay_root(path: &Path) -> Result<(), String> { if !path.is_absolute() || path.as_os_str().as_bytes().contains(&b',') { return Err("model relay root invalid".into()); @@ -404,7 +375,7 @@ fn relay_once( image .set_nonblocking(true) .map_err(|_| "model relay image socket invalid")?; - let request = read_frame(&mut image, deadline, cancelled)?; + let request = read_frame(&mut image, MODEL_FRAME_MAXIMUM, deadline, cancelled)?; let image_request: ImageModelRequest = serde_json::from_slice(&request).map_err(|_| "model relay request invalid")?; let wrapped = serde_json::to_vec(&ProxyModelRequest { @@ -417,15 +388,27 @@ fn relay_once( if wrapped.is_empty() || wrapped.len() > MODEL_FRAME_MAXIMUM { return Err("model relay request unbounded".into()); } - let mut proxy = connect_nonblocking(proxy_path, deadline, cancelled)?; - write_frame(&mut proxy, &wrapped, deadline, cancelled)?; - let response = read_frame(&mut proxy, deadline, cancelled)?; + let mut proxy = connect_private(proxy_path, deadline, cancelled)?; + write_frame( + &mut proxy, + &wrapped, + MODEL_FRAME_MAXIMUM, + deadline, + cancelled, + )?; + let response = read_frame(&mut proxy, MODEL_FRAME_MAXIMUM, deadline, cancelled)?; let response_value: ProxyModelResponse = serde_json::from_slice(&response).map_err(|_| "model relay response invalid")?; if !valid_proxy_response(&response_value) { return Err("model relay response invalid".into()); } - write_frame(&mut image, &response, deadline, cancelled) + write_frame( + &mut image, + &response, + MODEL_FRAME_MAXIMUM, + deadline, + cancelled, + ) } fn valid_proxy_response(response: &ProxyModelResponse) -> bool { @@ -469,162 +452,6 @@ fn valid_failure_code(value: &str) -> bool { .all(|item| item.is_ascii_lowercase() || item.is_ascii_digit() || item == b'_') } -fn read_frame( - stream: &mut UnixStream, - deadline: Instant, - cancelled: &AtomicBool, -) -> Result, String> { - let mut header = [0_u8; 4]; - transfer(stream, &mut header, deadline, cancelled, |stream, bytes| { - stream.read(bytes) - })?; - let size = u32::from_be_bytes(header) as usize; - if size == 0 || size > MODEL_FRAME_MAXIMUM { - return Err("model relay frame unbounded".into()); - } - let mut body = vec![0_u8; size]; - transfer(stream, &mut body, deadline, cancelled, |stream, bytes| { - stream.read(bytes) - })?; - Ok(body) -} - -fn write_frame( - stream: &mut UnixStream, - body: &[u8], - deadline: Instant, - cancelled: &AtomicBool, -) -> Result<(), String> { - if body.is_empty() || body.len() > MODEL_FRAME_MAXIMUM { - return Err("model relay frame unbounded".into()); - } - let mut header = (body.len() as u32).to_be_bytes(); - transfer(stream, &mut header, deadline, cancelled, |stream, bytes| { - stream.write(bytes) - })?; - let mut body = body.to_vec(); - transfer(stream, &mut body, deadline, cancelled, |stream, bytes| { - stream.write(bytes) - }) -} - -fn transfer( - stream: &mut UnixStream, - bytes: &mut [u8], - deadline: Instant, - cancelled: &AtomicBool, - mut operation: impl FnMut(&mut UnixStream, &mut [u8]) -> std::io::Result, -) -> Result<(), String> { - let mut offset = 0; - while offset < bytes.len() { - if cancelled.load(Ordering::Acquire) { - return Err("model relay cancelled".into()); - } - if Instant::now() >= deadline { - return Err("model relay deadline exceeded".into()); - } - match operation(stream, &mut bytes[offset..]) { - Ok(0) => return Err("model relay frame incomplete".into()), - Ok(count) => offset += count, - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(2)); - } - Err(_) => return Err("model relay I/O failed".into()), - } - } - Ok(()) -} - -fn connect_nonblocking( - path: &Path, - deadline: Instant, - cancelled: &AtomicBool, -) -> Result { - let bytes = path.as_os_str().as_bytes(); - if bytes.is_empty() || bytes.len() >= 108 || bytes.contains(&0) { - return Err("model proxy socket path invalid".into()); - } - // SAFETY: socket returns a new descriptor or -1 and has no borrowed inputs. - let descriptor = unsafe { - libc::socket( - libc::AF_UNIX, - libc::SOCK_STREAM | libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC, - 0, - ) - }; - if descriptor < 0 { - return Err("model proxy connection unavailable".into()); - } - // SAFETY: zero is a valid initialized representation for sockaddr_un. - let mut address: libc::sockaddr_un = unsafe { std::mem::zeroed() }; - address.sun_family = libc::AF_UNIX as libc::sa_family_t; - for (target, source) in address.sun_path.iter_mut().zip(bytes) { - *target = *source as libc::c_char; - } - let length = (std::mem::size_of::() + bytes.len() + 1) as libc::socklen_t; - // SAFETY: descriptor is owned and address/length describe initialized storage. - let connected = unsafe { - libc::connect( - descriptor, - (&address as *const libc::sockaddr_un).cast(), - length, - ) - }; - if connected != 0 { - let error = std::io::Error::last_os_error(); - if !matches!( - error.raw_os_error(), - Some(code) if code == libc::EINPROGRESS || code == libc::EAGAIN - ) { - // SAFETY: descriptor is owned and not converted into a UnixStream. - unsafe { libc::close(descriptor) }; - return Err("model proxy connection unavailable".into()); - } - loop { - if cancelled.load(Ordering::Acquire) || Instant::now() >= deadline { - // SAFETY: descriptor is owned and not converted into a UnixStream. - unsafe { libc::close(descriptor) }; - return Err("model proxy connection deadline exceeded".into()); - } - let mut poll = libc::pollfd { - fd: descriptor, - events: libc::POLLOUT, - revents: 0, - }; - // SAFETY: poll points to one initialized pollfd for the supplied count. - let ready = unsafe { libc::poll(&mut poll, 1, 2) }; - if ready < 0 { - // SAFETY: descriptor is owned and not converted into a UnixStream. - unsafe { libc::close(descriptor) }; - return Err("model proxy connection unavailable".into()); - } - if ready == 0 { - continue; - } - let mut socket_error: libc::c_int = 0; - let mut size = std::mem::size_of_val(&socket_error) as libc::socklen_t; - // SAFETY: pointers refer to writable initialized storage with exact size. - let status = unsafe { - libc::getsockopt( - descriptor, - libc::SOL_SOCKET, - libc::SO_ERROR, - (&mut socket_error as *mut libc::c_int).cast(), - &mut size, - ) - }; - if status != 0 || socket_error != 0 { - // SAFETY: descriptor is owned and not converted into a UnixStream. - unsafe { libc::close(descriptor) }; - return Err("model proxy connection unavailable".into()); - } - break; - } - } - // SAFETY: descriptor is a connected owned AF_UNIX stream descriptor. - Ok(unsafe { UnixStream::from_raw_fd(descriptor) }) -} - fn cleanup_paths(paths: &RelayPaths) -> bool { let socket_ok = match fs::symlink_metadata(&paths.socket) { Ok(metadata) @@ -916,7 +743,7 @@ mod tests { let mut closed = [0_u8; 1]; assert_eq!(attacker.read(&mut closed).unwrap(), 0); let result = first.worker.take().unwrap().join().unwrap(); - assert_eq!(result, Err("model relay frame unbounded".into())); + assert_eq!(result, Err("private proxy frame unbounded".into())); assert!(!first.finish().cleanup_unsettled); assert!(!second.finish().cleanup_unsettled); fs::remove_file(proxy_path).unwrap(); diff --git a/external_checkers/workstream-checker-service/src/proxy_transport.rs b/external_checkers/workstream-checker-service/src/proxy_transport.rs new file mode 100644 index 000000000..12f8bf94d --- /dev/null +++ b/external_checkers/workstream-checker-service/src/proxy_transport.rs @@ -0,0 +1,203 @@ +//! Transport-only private Unix framing shared by trusted invocation relays. + +use std::fs; +use std::io::{Read, Write}; +use std::os::fd::FromRawFd; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +pub(crate) fn validate_private_socket(path: &Path) -> Result<(), String> { + if !path.is_absolute() { + return Err("private proxy socket must be absolute".into()); + } + let parent = path + .parent() + .ok_or_else(|| "private proxy socket parent invalid".to_string())?; + let canonical_parent = + fs::canonicalize(parent).map_err(|_| "private proxy socket parent unavailable")?; + let parent_metadata = + fs::symlink_metadata(parent).map_err(|_| "private proxy socket parent unavailable")?; + // SAFETY: effective IDs are read-only process attributes. + let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) }; + if canonical_parent != parent + || !parent_metadata.file_type().is_dir() + || parent_metadata.file_type().is_symlink() + || parent_metadata.mode() & 0o777 != 0o700 + || parent_metadata.uid() != uid + || parent_metadata.gid() != gid + { + return Err("private proxy socket parent custody invalid".into()); + } + let metadata = fs::symlink_metadata(path).map_err(|_| "private proxy socket unavailable")?; + if metadata.file_type().is_symlink() + || !metadata.file_type().is_socket() + || metadata.mode() & 0o777 != 0o600 + || metadata.uid() != uid + || metadata.gid() != gid + { + return Err("private proxy socket invalid".into()); + } + Ok(()) +} + +pub(crate) fn read_frame( + stream: &mut UnixStream, + maximum: usize, + deadline: Instant, + cancelled: &AtomicBool, +) -> Result, String> { + let mut header = [0_u8; 4]; + transfer(stream, &mut header, deadline, cancelled, |stream, bytes| { + stream.read(bytes) + })?; + let size = u32::from_be_bytes(header) as usize; + if size == 0 || size > maximum { + return Err("private proxy frame unbounded".into()); + } + let mut body = vec![0_u8; size]; + transfer(stream, &mut body, deadline, cancelled, |stream, bytes| { + stream.read(bytes) + })?; + Ok(body) +} + +pub(crate) fn write_frame( + stream: &mut UnixStream, + body: &[u8], + maximum: usize, + deadline: Instant, + cancelled: &AtomicBool, +) -> Result<(), String> { + if body.is_empty() || body.len() > maximum || body.len() > u32::MAX as usize { + return Err("private proxy frame unbounded".into()); + } + let mut header = (body.len() as u32).to_be_bytes(); + transfer(stream, &mut header, deadline, cancelled, |stream, bytes| { + stream.write(bytes) + })?; + let mut body = body.to_vec(); + transfer(stream, &mut body, deadline, cancelled, |stream, bytes| { + stream.write(bytes) + }) +} + +fn transfer( + stream: &mut UnixStream, + bytes: &mut [u8], + deadline: Instant, + cancelled: &AtomicBool, + mut operation: impl FnMut(&mut UnixStream, &mut [u8]) -> std::io::Result, +) -> Result<(), String> { + let mut offset = 0; + while offset < bytes.len() { + if cancelled.load(Ordering::Acquire) { + return Err("private proxy operation cancelled".into()); + } + if Instant::now() >= deadline { + return Err("private proxy operation deadline exceeded".into()); + } + match operation(stream, &mut bytes[offset..]) { + Ok(0) => return Err("private proxy frame incomplete".into()), + Ok(count) => offset += count, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + } + Err(_) => return Err("private proxy I/O failed".into()), + } + } + Ok(()) +} + +pub(crate) fn connect_private( + path: &Path, + deadline: Instant, + cancelled: &AtomicBool, +) -> Result { + let bytes = path.as_os_str().as_bytes(); + if bytes.is_empty() || bytes.len() >= 108 || bytes.contains(&0) { + return Err("private proxy socket path invalid".into()); + } + // SAFETY: socket returns a new descriptor or -1 and has no borrowed inputs. + let descriptor = unsafe { + libc::socket( + libc::AF_UNIX, + libc::SOCK_STREAM | libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC, + 0, + ) + }; + if descriptor < 0 { + return Err("private proxy connection unavailable".into()); + } + // SAFETY: zero is a valid initialized representation for sockaddr_un. + let mut address: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + address.sun_family = libc::AF_UNIX as libc::sa_family_t; + for (target, source) in address.sun_path.iter_mut().zip(bytes) { + *target = *source as libc::c_char; + } + let length = (std::mem::size_of::() + bytes.len() + 1) as libc::socklen_t; + // SAFETY: descriptor is owned and address/length describe initialized storage. + let connected = unsafe { + libc::connect( + descriptor, + (&address as *const libc::sockaddr_un).cast(), + length, + ) + }; + if connected != 0 { + let error = std::io::Error::last_os_error(); + if !matches!( + error.raw_os_error(), + Some(code) if code == libc::EINPROGRESS || code == libc::EAGAIN + ) { + // SAFETY: descriptor is owned and not converted into a UnixStream. + unsafe { libc::close(descriptor) }; + return Err("private proxy connection unavailable".into()); + } + loop { + if cancelled.load(Ordering::Acquire) || Instant::now() >= deadline { + // SAFETY: descriptor is owned and not converted into a UnixStream. + unsafe { libc::close(descriptor) }; + return Err("private proxy connection deadline exceeded".into()); + } + let mut poll = libc::pollfd { + fd: descriptor, + events: libc::POLLOUT, + revents: 0, + }; + // SAFETY: poll points to one initialized pollfd for the supplied count. + let ready = unsafe { libc::poll(&mut poll, 1, 2) }; + if ready < 0 { + // SAFETY: descriptor is owned and not converted into a UnixStream. + unsafe { libc::close(descriptor) }; + return Err("private proxy connection unavailable".into()); + } + if ready == 0 { + continue; + } + let mut socket_error: libc::c_int = 0; + let mut size = std::mem::size_of_val(&socket_error) as libc::socklen_t; + // SAFETY: pointers refer to writable initialized storage with exact size. + let status = unsafe { + libc::getsockopt( + descriptor, + libc::SOL_SOCKET, + libc::SO_ERROR, + (&mut socket_error as *mut libc::c_int).cast(), + &mut size, + ) + }; + if status != 0 || socket_error != 0 { + // SAFETY: descriptor is owned and not converted into a UnixStream. + unsafe { libc::close(descriptor) }; + return Err("private proxy connection unavailable".into()); + } + break; + } + } + // SAFETY: descriptor is uniquely owned and successful connect completed above. + Ok(unsafe { UnixStream::from_raw_fd(descriptor) }) +} From 01c9803175d075b9f7dd6f7751228f16362f69d0 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 18:48:53 +0100 Subject: [PATCH 010/115] feat(model-usage): support bounded shared sessions --- .ci/auth-boundaries/TEST_STRUCTURE_DEBT.json | 26 +- .commitrail/changes/checker-model-runtime.md | 17 +- backend/alembic/versions/0060_model_usage.py | 172 ++- .../app/adapters/checkers/model_execution.py | 6 +- backend/app/adapters/checkers/model_proxy.py | 122 +- .../checkers/model_proxy_transport.py | 28 +- backend/app/interfaces/checker_model_proxy.py | 64 +- .../app/interfaces/project_guide_runtime.py | 21 +- .../authorization/action_definitions.py | 727 ++++++++++++ .../app/modules/authorization/catalogue.py | 1031 +++-------------- backend/app/modules/authorization/kernel.py | 438 ++++--- .../app/modules/checkers/api/model_grants.py | 8 - backend/app/modules/model_usage/api.py | 14 +- backend/app/modules/model_usage/grants.py | 99 +- backend/app/modules/model_usage/models.py | 44 +- .../app/modules/model_usage/owner_ports.py | 19 +- backend/app/modules/model_usage/setup.py | 31 +- .../projects/guide_compilation/model_usage.py | 39 +- backend/scripts/test_lane_catalogue.py | 1 + .../external/test_model_proxy_transport.py | 40 +- .../test_composed_execution.py | 7 +- ...test_model_call_transactions_postgresql.py | 827 ++----------- ...test_model_invocation_grants_postgresql.py | 125 +- .../test_model_shared_setup_postgresql.py | 644 ++++++++++ .../test_proxy_orchestration.py | 11 +- backend/tests/conftest.py | 4 +- backend/tests/test_ci_lane_catalogue.py | 9 +- external_checkers/README.md | 25 +- 28 files changed, 2526 insertions(+), 2073 deletions(-) create mode 100644 backend/app/modules/authorization/action_definitions.py delete mode 100644 backend/app/modules/checkers/api/model_grants.py create mode 100644 backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py diff --git a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json index d2f9e9878..1192f6fbd 100644 --- a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json +++ b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json @@ -2,11 +2,11 @@ "entries": [ { "capability": "oversized_authorization_structure", - "content_sha256": "9bd965c903b2b2586248f127f9b1e991bb5871659d624b5f69f3b4a2b2499329", - "end_line": 1269, + "content_sha256": "b4f2fe63f88cacf12830a5cc556f8dc627e74deac80c1b6183d13a8a1a4f52ce", + "end_line": 1265, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1269, + "observed_lines": 1265, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -26,27 +26,15 @@ }, { "capability": "oversized_authorization_structure", - "content_sha256": "75bcb2639cd1f099597c8b07efa9b6d78d334ea3f092a1bf89ef638417b5856f", - "end_line": 488, + "content_sha256": "bae6d1bf2568947ee4125b3389d861fb7c077a346da1efcef3167b68ce7cafd5", + "end_line": 818, "hard_limit": 100, "kind": "production_function", - "observed_lines": 101, - "path": "backend/app/modules/authorization/kernel.py", - "qualified_symbol": "AuthorizationService._prepare_prelocked", - "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 388 - }, - { - "capability": "oversized_authorization_structure", - "content_sha256": "a2bcc3b6fbfb59e65e5c606b777378451733f5fd11c9aeccf9f2e05773b2361f", - "end_line": 812, - "hard_limit": 100, - "kind": "production_function", - "observed_lines": 106, + "observed_lines": 105, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._require_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 707 + "start_line": 714 }, { "capability": "oversized_authorization_structure", diff --git a/.commitrail/changes/checker-model-runtime.md b/.commitrail/changes/checker-model-runtime.md index 61a53a3b0..6b8b5e42d 100644 --- a/.commitrail/changes/checker-model-runtime.md +++ b/.commitrail/changes/checker-model-runtime.md @@ -40,7 +40,9 @@ development names or introduce a second meter. - CHECKERS selected POST integration and existing AUTH, PROJECTS and TASK ports for fresh owner currentness and Operator provisioning. - Existing ART generated checker-output custody to retain bounded private - request/response evidence, with matching authority and read concealment. + request/response evidence, with matching authority and read concealment; the + same put/verify/bind/recover pipeline accepts one typed CHECKERS-owned trial + evidence subject so trusted observations are durable before a paid assessment. - Typed provider capability under `backend/app/interfaces/`, concrete adapter and explicit ADR-0014 composition; fixed settings outside images. - One linear Alembic continuation with preflight, intermediate upgrade and @@ -157,6 +159,19 @@ run-only write selector and deny-only producer composition are replaced; retaine historical rows keep their immutable ancestry. Private call bindings remain retained independently of the original checker run. +Execution tools use a separate immutable `checker_trial_evidence` owner selected +after a canonical plan is validated and before its engine runs. Its replay key is +the stable project/task/assignment/Submission, requirement, registry/configuration, +trial kind/slot, exact plan and input/material digests. The originating run, lease +and external-request digest are retained provenance but never the replay key. +The first completed canonical receipt pins its content digest through the same +ART put, independent verification and immutable binding path; later workers may +only recover those exact bytes. An invocation-local opaque handle is minted only +after the ART binding acknowledgement and may be replaced on retry. Handles and +lease identities never enter the paid request hash; the exact verified canonical +receipt bytes and digest do. CHECKERS owns trial reservation/currentness and the +0061 strong owner schema; MODEL-USAGE owns only the bounded resolver/session seam. + The local relay root must be mounted at the same Docker-daemon-visible path, with private same-UID socket custody. gVisor requires the administrator-provided `host-uds=open` configuration for this mounted socket; Docker development proof diff --git a/backend/alembic/versions/0060_model_usage.py b/backend/alembic/versions/0060_model_usage.py index 55e2ef935..5467a2a6d 100644 --- a/backend/alembic/versions/0060_model_usage.py +++ b/backend/alembic/versions/0060_model_usage.py @@ -25,7 +25,7 @@ def upgrade() -> None: def _upgrade_invocation_grants() -> None: - """Create one-use invocation grants after their retained call-ledger parents.""" + """Create bounded invocation sessions after their retained call-ledger parents.""" op.execute( r""" CREATE TABLE public.checker_model_invocation_grants ( @@ -40,24 +40,14 @@ def _upgrade_invocation_grants() -> None: expires_at timestamptz NOT NULL, issued_at timestamptz DEFAULT clock_timestamp() NOT NULL, authorization_evidence_id uuid NOT NULL, - selected_request_sha256 varchar(71), - selected_attempt_id uuid, - consumed_at timestamptz, CONSTRAINT pk_checker_model_invocation_grants PRIMARY KEY(id), CONSTRAINT ck_checker_model_invocation_grants_id_uuid7 CHECK ( (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), CONSTRAINT ck_checker_model_invocation_grants_digests CHECK ( token_hash ~ '^sha256:[0-9a-f]{64}$' AND - external_request_digest ~ '^sha256:[0-9a-f]{64}$' AND - (selected_request_sha256 IS NULL OR - selected_request_sha256 ~ '^sha256:[0-9a-f]{64}$')), + external_request_digest ~ '^sha256:[0-9a-f]{64}$'), CONSTRAINT ck_checker_model_invocation_grants_json CHECK ( jsonb_typeof(execution_facts)='object' AND jsonb_typeof(paid_slot)='object'), - CONSTRAINT ck_checker_model_invocation_grants_consumption CHECK ( - (consumed_at IS NULL AND selected_request_sha256 IS NULL AND - selected_attempt_id IS NULL) OR - (consumed_at IS NOT NULL AND selected_request_sha256 IS NOT NULL AND - selected_attempt_id IS NOT NULL)), CONSTRAINT uq_checker_model_invocation_grants_token UNIQUE(token_hash), CONSTRAINT uq_checker_model_invocation_grants_authority UNIQUE(authorization_evidence_id), @@ -69,9 +59,7 @@ def _upgrade_invocation_grants() -> None: REFERENCES public.external_checker_registry_entries(id) ON DELETE RESTRICT, CONSTRAINT fk_checker_model_invocation_grants_authority FOREIGN KEY(authorization_evidence_id) - REFERENCES public.audit_events(id) ON DELETE RESTRICT, - CONSTRAINT fk_checker_model_invocation_grants_attempt FOREIGN KEY(selected_attempt_id) - REFERENCES public.model_usage_effects(id) ON DELETE RESTRICT + REFERENCES public.audit_events(id) ON DELETE RESTRICT ) """ ) @@ -93,8 +81,6 @@ def _upgrade_invocation_grants() -> None: DECLARE run public.checker_runs%ROWTYPE; DECLARE registry public.external_checker_registry_entries%ROWTYPE; DECLARE profile public.model_usage_profiles%ROWTYPE; -DECLARE attempt public.model_usage_effects%ROWTYPE; -DECLARE slot public.checker_model_usage_slots%ROWTYPE; DECLARE binding_matches integer; BEGIN IF TG_OP='DELETE' THEN @@ -102,46 +88,8 @@ def _upgrade_invocation_grants() -> None: USING ERRCODE='55000'; END IF; IF TG_OP='UPDATE' THEN - IF OLD.consumed_at IS NOT NULL OR - NEW.selected_request_sha256 IS NULL OR NEW.selected_attempt_id IS NULL OR - NEW.consumed_at IS NULL OR - (to_jsonb(NEW)-ARRAY['selected_request_sha256','selected_attempt_id','consumed_at']) - IS DISTINCT FROM - (to_jsonb(OLD)-ARRAY['selected_request_sha256','selected_attempt_id','consumed_at']) THEN - RAISE EXCEPTION 'checker model invocation grant facts are immutable' - USING ERRCODE='55000'; - END IF; - IF OLD.expires_at<=clock_timestamp() THEN - RAISE EXCEPTION 'checker model invocation grant expired' - USING ERRCODE='23514'; - END IF; - SELECT value.* INTO attempt FROM public.model_usage_effects value - WHERE value.id=NEW.selected_attempt_id FOR SHARE; - SELECT value.* INTO slot FROM public.checker_model_usage_slots value - WHERE value.id=attempt.checker_slot_id FOR SHARE; - IF attempt.id IS NULL OR slot.id IS NULL OR - attempt.request_sha256 IS DISTINCT FROM NEW.selected_request_sha256 OR - slot.project_id IS DISTINCT FROM NEW.project_id OR - slot.task_id IS DISTINCT FROM (NEW.execution_facts->>'task_id')::uuid OR - slot.assignment_id IS DISTINCT FROM (NEW.paid_slot->>'assignment_id')::uuid OR - slot.submission_id IS DISTINCT FROM (NEW.paid_slot->>'submission_id')::uuid OR - slot.submission_version IS DISTINCT FROM - (NEW.paid_slot->>'submission_version')::integer OR - slot.requirement_id IS DISTINCT FROM NEW.paid_slot->>'requirement_id' OR - slot.registry_entry_digest IS DISTINCT FROM - NEW.paid_slot->>'registry_entry_digest' OR - slot.configuration_sha256 IS DISTINCT FROM - NEW.paid_slot->>'configuration_sha256' OR - slot.profile_id IS DISTINCT FROM (NEW.paid_slot->>'profile_id')::uuid OR - slot.profile_version IS DISTINCT FROM - (NEW.paid_slot->>'profile_version')::integer OR - slot.profile_digest IS DISTINCT FROM NEW.paid_slot->>'profile_digest' OR - slot.slot_index IS DISTINCT FROM (NEW.paid_slot->>'slot_index')::integer THEN - RAISE EXCEPTION 'checker model invocation grant attempt ancestry is invalid' - USING ERRCODE='23514'; - END IF; - NEW.consumed_at:=clock_timestamp(); - RETURN NEW; + RAISE EXCEPTION 'checker model invocation grant facts are immutable' + USING ERRCODE='55000'; END IF; IF jsonb_typeof(NEW.execution_facts)<>'object' OR @@ -237,8 +185,6 @@ def _upgrade_invocation_grants() -> None: profile_digest=NEW.paid_slot->>'profile_digest' FOR SHARE; NEW.issued_at:=clock_timestamp(); IF NOT isfinite(NEW.expires_at) OR NEW.expires_at<=clock_timestamp() OR - NEW.consumed_at IS NOT NULL OR NEW.selected_request_sha256 IS NOT NULL OR - NEW.selected_attempt_id IS NOT NULL OR (NEW.execution_facts->>'project_id')::uuid IS DISTINCT FROM NEW.project_id OR (NEW.paid_slot->>'project_id')::uuid IS DISTINCT FROM NEW.project_id OR (NEW.execution_facts->>'checker_run_id')::uuid IS DISTINCT FROM NEW.checker_run_id OR @@ -314,6 +260,92 @@ def _upgrade_invocation_grants() -> None: r""" CREATE TRIGGER checker_model_invocation_grants_no_truncate BEFORE TRUNCATE ON public.checker_model_invocation_grants + FOR EACH STATEMENT EXECUTE FUNCTION public.guard_model_usage_no_truncate() + """ + ) + op.execute( + r""" +CREATE TABLE public.checker_model_invocation_grant_calls ( + grant_id uuid NOT NULL, + call_index integer NOT NULL, + attempt_id uuid NOT NULL, + request_sha256 varchar(71) NOT NULL, + bound_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_checker_model_invocation_grant_calls PRIMARY KEY(grant_id,call_index), + CONSTRAINT uq_checker_model_invocation_grant_calls_attempt UNIQUE(attempt_id), + CONSTRAINT ck_checker_model_invocation_grant_calls_facts CHECK ( + call_index BETWEEN 0 AND 99 AND request_sha256 ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT fk_checker_model_invocation_grant_calls_grant FOREIGN KEY(grant_id) + REFERENCES public.checker_model_invocation_grants(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_invocation_grant_calls_attempt FOREIGN KEY(attempt_id) + REFERENCES public.model_usage_effects(id) ON DELETE RESTRICT +) + """ + ) + op.execute( + r""" +CREATE FUNCTION public.guard_checker_model_invocation_grant_call() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE grant_row public.checker_model_invocation_grants%ROWTYPE; +DECLARE attempt public.model_usage_effects%ROWTYPE; +DECLARE slot public.checker_model_usage_slots%ROWTYPE; +DECLARE profile public.model_usage_profiles%ROWTYPE; +DECLARE prior_calls bigint; +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'checker model invocation grant calls are immutable' + USING ERRCODE='55000'; + END IF; + SELECT * INTO grant_row FROM public.checker_model_invocation_grants + WHERE id=NEW.grant_id FOR UPDATE; + SELECT * INTO attempt FROM public.model_usage_effects + WHERE id=NEW.attempt_id FOR SHARE; + SELECT * INTO slot FROM public.checker_model_usage_slots + WHERE id=attempt.checker_slot_id FOR SHARE; + SELECT * INTO profile FROM public.model_usage_profiles + WHERE id=slot.profile_id AND profile_version=slot.profile_version AND + profile_digest=slot.profile_digest FOR SHARE; + SELECT count(*) INTO prior_calls FROM public.checker_model_invocation_grant_calls + WHERE grant_id=NEW.grant_id; + IF grant_row.id IS NULL OR grant_row.expires_at<=clock_timestamp() OR + attempt.id IS NULL OR slot.id IS NULL OR profile.id IS NULL OR + NEW.call_index<>prior_calls OR NEW.call_index>=profile.maximum_calls OR + attempt.request_sha256 IS DISTINCT FROM NEW.request_sha256 OR + slot.slot_index IS DISTINCT FROM NEW.call_index OR + slot.project_id IS DISTINCT FROM grant_row.project_id OR + slot.task_id IS DISTINCT FROM (grant_row.execution_facts->>'task_id')::uuid OR + slot.assignment_id IS DISTINCT FROM (grant_row.paid_slot->>'assignment_id')::uuid OR + slot.submission_id IS DISTINCT FROM (grant_row.paid_slot->>'submission_id')::uuid OR + slot.submission_version IS DISTINCT FROM + (grant_row.paid_slot->>'submission_version')::integer OR + slot.requirement_id IS DISTINCT FROM grant_row.paid_slot->>'requirement_id' OR + slot.registry_entry_digest IS DISTINCT FROM + grant_row.paid_slot->>'registry_entry_digest' OR + slot.configuration_sha256 IS DISTINCT FROM + grant_row.paid_slot->>'configuration_sha256' OR + slot.profile_id IS DISTINCT FROM (grant_row.paid_slot->>'profile_id')::uuid OR + slot.profile_version IS DISTINCT FROM + (grant_row.paid_slot->>'profile_version')::integer OR + slot.profile_digest IS DISTINCT FROM grant_row.paid_slot->>'profile_digest' THEN + RAISE EXCEPTION 'checker model invocation grant call ancestry is invalid' + USING ERRCODE='23514'; + END IF; + NEW.bound_at:=clock_timestamp(); + RETURN NEW; +END $$ + """ + ) + op.execute( + r""" +CREATE TRIGGER checker_model_invocation_grant_calls_guard + BEFORE INSERT OR UPDATE OR DELETE ON public.checker_model_invocation_grant_calls + FOR EACH ROW EXECUTE FUNCTION public.guard_checker_model_invocation_grant_call() + """ + ) + op.execute( + r""" +CREATE TRIGGER checker_model_invocation_grant_calls_no_truncate + BEFORE TRUNCATE ON public.checker_model_invocation_grant_calls FOR EACH STATEMENT EXECUTE FUNCTION public.guard_model_usage_no_truncate() """ ) @@ -1139,6 +1171,8 @@ def downgrade() -> None: CONSTRAINT uq_setup_model_usage_envelopes_stable_identity UNIQUE( compilation_attempt_id,runtime_configuration_hash,profile_id,profile_version, profile_digest), + CONSTRAINT uq_setup_model_usage_envelopes_one_per_attempt UNIQUE( + compilation_attempt_id), CONSTRAINT fk_setup_model_usage_envelopes_attempt FOREIGN KEY( compilation_attempt_id,project_id,guide_id,source_snapshot_id,setup_run_id, setup_generation,runtime_configuration_hash) @@ -1673,6 +1707,16 @@ def downgrade() -> None: attempt.setup_run_id IS DISTINCT FROM NEW.setup_run_id OR attempt.setup_generation IS DISTINCT FROM NEW.setup_generation OR attempt.runtime_configuration_hash IS DISTINCT FROM NEW.runtime_configuration_hash OR + jsonb_typeof(attempt.runtime_configuration::jsonb) IS DISTINCT FROM 'object' OR + attempt.runtime_configuration->>'model_profile_id' IS DISTINCT FROM + NEW.profile_id::text OR + (attempt.runtime_configuration->>'model_profile_version')::integer IS DISTINCT FROM + NEW.profile_version OR + attempt.runtime_configuration->>'model_profile_digest' IS DISTINCT FROM + NEW.profile_digest OR + attempt.runtime_configuration->>'model_provider' IS DISTINCT FROM profile.provider OR + attempt.runtime_configuration->>'model_api' IS DISTINCT FROM profile.api OR + attempt.runtime_configuration->>'model' IS DISTINCT FROM profile.model_snapshot OR profile.project_id::text IS DISTINCT FROM NEW.project_id::text THEN RAISE EXCEPTION 'invalid setup model usage envelope ownership' USING ERRCODE='23514'; END IF; @@ -1703,6 +1747,16 @@ def downgrade() -> None: attempt.setup_run_id IS DISTINCT FROM NEW.setup_run_id OR attempt.setup_generation IS DISTINCT FROM NEW.setup_generation OR attempt.runtime_configuration_hash IS DISTINCT FROM NEW.runtime_configuration_hash OR + jsonb_typeof(attempt.runtime_configuration::jsonb) IS DISTINCT FROM 'object' OR + attempt.runtime_configuration->>'model_profile_id' IS DISTINCT FROM + NEW.profile_id::text OR + (attempt.runtime_configuration->>'model_profile_version')::integer IS DISTINCT FROM + NEW.profile_version OR + attempt.runtime_configuration->>'model_profile_digest' IS DISTINCT FROM + NEW.profile_digest OR + attempt.runtime_configuration->>'model_provider' IS DISTINCT FROM profile.provider OR + attempt.runtime_configuration->>'model_api' IS DISTINCT FROM profile.api OR + attempt.runtime_configuration->>'model' IS DISTINCT FROM profile.model_snapshot OR ROW(envelope.compilation_attempt_id,envelope.project_id,envelope.guide_id, envelope.source_snapshot_id,envelope.setup_run_id,envelope.setup_generation, envelope.runtime_configuration_hash,envelope.profile_id, diff --git a/backend/app/adapters/checkers/model_execution.py b/backend/app/adapters/checkers/model_execution.py index ae898818f..db1f1fd4d 100644 --- a/backend/app/adapters/checkers/model_execution.py +++ b/backend/app/adapters/checkers/model_execution.py @@ -87,12 +87,14 @@ async def execute(self, request, grant, *, model_grant=None): authority = await checker_model_service_authorization( session, facts.evaluation_request_id ) - effect = await model_invocation_grants(session, authority).completed_effect( + effects = await model_invocation_grants(session, authority).completed_effects( callback.grant_id, request.request_digest, facts, ) - await model_call_service(session, authority).attach_completed(facts, effect) + calls = model_call_service(session, authority) + for effect in effects: + await calls.attach_completed(facts, effect) return response except ModelCallUnavailable as error: raise ExternalServiceUnavailableError(self.identity) from error diff --git a/backend/app/adapters/checkers/model_proxy.py b/backend/app/adapters/checkers/model_proxy.py index a0e5a9e83..25609bc08 100644 --- a/backend/app/adapters/checkers/model_proxy.py +++ b/backend/app/adapters/checkers/model_proxy.py @@ -24,6 +24,7 @@ from app.modules.model_usage.api import ( ModelCallEffectAuthorityFacts, ModelCallEvidence, + ModelCallBudgetUnavailable, ModelCallUsage, ModelCallUnavailable, ModelProfileSelector, @@ -51,8 +52,20 @@ def _sha(value: bytes) -> str: return "sha256:" + hashlib.sha256(value).hexdigest() -def _unavailable(code: str) -> ModelProxyReply: - return ModelProxyReply("unavailable", None, None, code) +def _unavailable( + code: str, + call_index: int, + *, + session_exhausted: bool = True, +) -> ModelProxyReply: + return ModelProxyReply( + outcome="unavailable", + output=None, + receipt_digest=None, + failure_code=code, + call_index=call_index, + session_exhausted=session_exhausted, + ) class CheckerModelProxy: @@ -80,26 +93,49 @@ async def handle(self, request: ModelProxyRequest) -> ModelProxyReply: """Consume one invocation capability; failed infrastructure never becomes a verdict.""" try: return await self._handle(request) + except ModelCallBudgetUnavailable: + return _unavailable("model_budget_unavailable", request.call_index) except (ModelCallUnavailable, CheckerOutputUnavailable): - return _unavailable("model_call_unavailable") + return _unavailable("model_call_unavailable", request.call_index) async def _handle(self, request: ModelProxyRequest) -> ModelProxyReply: - # Reject malformed bytes before consuming invocation custody or reserving spend. - try: - provider_request = CheckerModelRequest(request.prompt) - except ValueError: - return _unavailable("model_request_invalid") + resolved_receipts = [ + { + "receipt_sha256": receipt.receipt_sha256, + "receipt": json.loads(receipt.canonical_receipt_utf8), + } + for receipt in request.resolved_receipts + ] data = _canonical_bytes( { - "schema_version": "workstream.checker_model_request.v1", + "schema_version": "workstream.model_session_call.v2", + "call_index": request.call_index, "prompt": request.prompt, + "resolved_receipts": resolved_receipts, } ) + # The trusted relay resolves opaque handles; providers receive the exact retained + # facts, never image-authored receipt claims. Keep the ordinary prompt unchanged + # for calls without tools while using one closed envelope when receipts are present. + provider_prompt = request.prompt + if resolved_receipts: + provider_prompt = _canonical_bytes( + { + "schema_version": "workstream.model_prompt_with_receipts.v1", + "prompt": request.prompt, + "trusted_receipts": resolved_receipts, + } + ).decode("utf-8") + try: + provider_request = CheckerModelRequest(provider_prompt) + except ValueError: + return _unavailable("model_request_invalid", request.call_index) request_sha = _sha(data) async with self._sessions() as session, session.begin(): authority = await self._authorization(session, uuid4()) grants = self._grants(session, authority) facts = await grants.resolve(request.grant_id, request.request_digest) + paid_slot = facts.slot.model_copy(update={"slot_index": request.call_index}) profile = await self._profiles(session).read_exact( ModelProfileSelector( profile_id=facts.slot.profile_id, @@ -107,19 +143,30 @@ async def _handle(self, request: ModelProxyRequest) -> ModelProxyReply: profile_digest=facts.slot.profile_digest, ), project_id=facts.slot.project_id, - registry_entry_id=facts.registry_entry_id, - registry_entry_digest=facts.slot.registry_entry_digest, ) - if len(data) > profile.spec.maximum_request_bytes: - return _unavailable("model_request_too_large") + receipt_bytes = sum( + len(receipt.canonical_receipt_utf8.encode("utf-8")) + for receipt in request.resolved_receipts + ) + if ( + request.call_index >= profile.spec.maximum_calls + or len(request.resolved_receipts) > profile.spec.maximum_tool_calls + or receipt_bytes > profile.spec.maximum_tool_output_bytes + ): + return _unavailable("model_session_exhausted", request.call_index) + if max(len(data), len(provider_prompt.encode("utf-8"))) > ( + profile.spec.maximum_request_bytes + ): + return _unavailable("model_request_too_large", request.call_index) attached = await self._calls(session, authority).prepare_attempt( facts.execution, - facts.slot, + paid_slot, registry_entry_id=facts.registry_entry_id, request_sha256=request_sha, ) await grants.consume_prepared( facts, + call_index=request.call_index, request_sha256=request_sha, prepared_attempt_id=attached.attempt_id, ) @@ -132,9 +179,9 @@ async def _handle(self, request: ModelProxyRequest) -> ModelProxyReply: internal_call_request_id=attached.internal_call_request_id, ) if attached.state == "completed": - return await self._cached(facts, effect) + return await self._cached(facts, effect, profile, request.call_index) if attached.state in {"known_failed", "known_no_effect"}: - return _unavailable("model_call_operator_recovery_required") + return _unavailable("model_call_operator_recovery_required", request.call_index) async with self._provider(profile) as provider: dispatch = None if attached.state == "prepared": @@ -150,7 +197,7 @@ async def _handle(self, request: ModelProxyRequest) -> ModelProxyReply: request_evidence, ) if dispatch.state == "completed": - return await self._cached(facts, effect) + return await self._cached(facts, effect, profile, request.call_index) deadline = asyncio.get_running_loop().time() + profile.spec.deadline_ms / 1000 if dispatch is not None and not dispatch.replayed: try: @@ -160,7 +207,7 @@ async def _handle(self, request: ModelProxyRequest) -> ModelProxyReply: ) except TimeoutError: await self._record_reference(effect, None) - return _unavailable("model_provider_delivery_uncertain") + return _unavailable("model_provider_delivery_uncertain", request.call_index) if created.outcome is CheckerModelCreateOutcome.NO_EFFECT: async with self._sessions() as session, session.begin(): authority = await self._authorization( @@ -178,11 +225,11 @@ async def _handle(self, request: ModelProxyRequest) -> ModelProxyReply: observed_at=await session.scalar(select(func.clock_timestamp())), ) await self._calls(session, authority).mark_known_no_effect(effect, proof) - return _unavailable("model_provider_no_effect") + return _unavailable("model_provider_no_effect", request.call_index) reference = created.known_response await self._record_reference(effect, reference) if created.outcome is CheckerModelCreateOutcome.UNCERTAIN: - return _unavailable("model_provider_delivery_uncertain") + return _unavailable("model_provider_delivery_uncertain", request.call_index) observation = created.observation else: async with self._sessions() as session, session.begin(): @@ -200,11 +247,13 @@ async def _handle(self, request: ModelProxyRequest) -> ModelProxyReply: CheckerModelProviderStatus.IN_PROGRESS, }: if observation.usage is None: - return _unavailable("model_provider_usage_unavailable") - return await self._complete(facts, effect, profile, observation) + return _unavailable("model_provider_usage_unavailable", request.call_index) + return await self._complete( + facts, effect, profile, observation, request.call_index + ) remaining = deadline - asyncio.get_running_loop().time() if remaining <= 0: - return _unavailable("model_provider_pending") + return _unavailable("model_provider_pending", request.call_index) await asyncio.sleep(min(1, remaining)) try: recovered = await asyncio.wait_for( @@ -212,9 +261,11 @@ async def _handle(self, request: ModelProxyRequest) -> ModelProxyReply: timeout=max(0.001, deadline - asyncio.get_running_loop().time()), ) except TimeoutError: - return _unavailable("model_provider_pending") + return _unavailable("model_provider_pending", request.call_index) if recovered.outcome is not CheckerModelRecoveryOutcome.OBSERVED: - return _unavailable("model_provider_recovery_unavailable") + return _unavailable( + "model_provider_recovery_unavailable", request.call_index + ) observation = recovered.observation async def _record_reference(self, effect, reference) -> None: @@ -263,7 +314,9 @@ async def source(): byte_count=len(data), ) - async def _complete(self, facts, effect, profile, observation) -> ModelProxyReply: + async def _complete( + self, facts, effect, profile, observation, call_index: int + ) -> ModelProxyReply: """Record authenticated terminal evidence before settling once, including late results.""" data = _canonical_bytes( { @@ -272,7 +325,7 @@ async def _complete(self, facts, effect, profile, observation) -> ModelProxyRepl } ) if len(data) > profile.spec.maximum_response_bytes: - return _unavailable("model_response_too_large") + return _unavailable("model_response_too_large", call_index) evidence = await self._store(facts, effect, "model_response", data) raw_usage = observation.usage usage = ModelCallUsage( @@ -296,11 +349,11 @@ async def _complete(self, facts, effect, profile, observation) -> ModelProxyRepl usage=usage, ) if not usable: - return _unavailable("model_provider_failed") + return _unavailable("model_provider_failed", call_index) # Fresh current-run attachment gates image access; late completion itself remains retained. - return await self._cached(facts, effect) + return await self._cached(facts, effect, profile, call_index) - async def _cached(self, facts, effect) -> ModelProxyReply: + async def _cached(self, facts, effect, profile, call_index: int) -> ModelProxyReply: """Reauthorize current invocation before releasing the original verified response.""" async with self._sessions() as session, session.begin(): authority = await self._authorization(session, facts.execution.evaluation_request_id) @@ -324,4 +377,11 @@ async def _cached(self, facts, effect) -> ModelProxyReply: raise ModelCallUnavailable("model_call_response_unavailable") output = body["observation"]["output_text"] receipt = canonical_json_hash(completed.model_dump(mode="json", exclude={"replayed"})) - return ModelProxyReply("completed", output, receipt, None) + return ModelProxyReply( + outcome="completed", + output=output, + receipt_digest=receipt, + failure_code=None, + call_index=call_index, + session_exhausted=call_index + 1 >= profile.spec.maximum_calls, + ) diff --git a/backend/app/adapters/checkers/model_proxy_transport.py b/backend/app/adapters/checkers/model_proxy_transport.py index a68cadadc..ab4d5ac68 100644 --- a/backend/app/adapters/checkers/model_proxy_transport.py +++ b/backend/app/adapters/checkers/model_proxy_transport.py @@ -16,6 +16,7 @@ ModelProxyHandler, ModelProxyReply, ModelProxyRequest, + ModelProxyResolvedReceipt, ) @@ -291,7 +292,14 @@ def _decode_request(payload: bytes) -> ModelProxyRequest: parse_constant=_reject_json_constant, object_pairs_hook=_closed_json_object, ) - expected = {"protocol_version", "grant_id", "request_digest", "prompt"} + expected = { + "protocol_version", + "grant_id", + "request_digest", + "call_index", + "prompt", + "resolved_receipts", + } if type(value) is not dict or set(value) != expected: raise ValueError("model proxy request root is invalid") if value["protocol_version"] != MODEL_PROXY_PROTOCOL: @@ -299,10 +307,28 @@ def _decode_request(payload: bytes) -> ModelProxyRequest: return ModelProxyRequest( grant_id=value["grant_id"], request_digest=value["request_digest"], + call_index=value["call_index"], prompt=value["prompt"], + resolved_receipts=tuple( + ModelProxyResolvedReceipt( + receipt_sha256=item["receipt_sha256"], + canonical_receipt_utf8=item["canonical_receipt_utf8"], + ) + for item in _resolved_receipt_values(value["resolved_receipts"]) + ), ) +def _resolved_receipt_values(value: object) -> list[dict[str, object]]: + """Reject open or duplicate receipt objects before constructing trusted values.""" + if type(value) is not list: + raise ValueError("model proxy resolved receipts are invalid") + expected = {"receipt_sha256", "canonical_receipt_utf8"} + if any(type(item) is not dict or set(item) != expected for item in value): + raise ValueError("model proxy resolved receipts are invalid") + return value + + def _closed_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: value: dict[str, Any] = {} for key, item in pairs: diff --git a/backend/app/interfaces/checker_model_proxy.py b/backend/app/interfaces/checker_model_proxy.py index a0c113c7a..14c5b7ad0 100644 --- a/backend/app/interfaces/checker_model_proxy.py +++ b/backend/app/interfaces/checker_model_proxy.py @@ -1,28 +1,75 @@ -"""Typed private protocol for request-bound checker model calls.""" +"""Typed private protocol for bounded invocation-local model sessions.""" from collections.abc import Awaitable, Callable from dataclasses import dataclass +import hashlib +import json -MODEL_PROXY_PROTOCOL = "workstream.checker_model_proxy.v1" +MODEL_PROXY_PROTOCOL = "workstream.model_session.v2" MODEL_PROXY_MAXIMUM_FRAME_BYTES = 1024 * 1024 +MODEL_PROXY_MAXIMUM_RECEIPTS = 1000 _MODEL_GRANT_PREFIX = "model_" _SHA256_PREFIX = "sha256:" +@dataclass(frozen=True, slots=True, repr=False) +class ModelProxyResolvedReceipt: + """Exact retained tool receipt resolved by trusted invocation code.""" + + receipt_sha256: str + canonical_receipt_utf8: str + + def __post_init__(self) -> None: + if not _valid_sha256(self.receipt_sha256) or type(self.canonical_receipt_utf8) is not str: + raise ValueError("model proxy receipt is invalid") + try: + encoded = self.canonical_receipt_utf8.encode("utf-8") + value = json.loads(self.canonical_receipt_utf8) + except (UnicodeError, ValueError, TypeError): + raise ValueError("model proxy receipt is invalid") from None + if ( + not encoded + or len(encoded) > MODEL_PROXY_MAXIMUM_FRAME_BYTES + or "sha256:" + hashlib.sha256(encoded).hexdigest() != self.receipt_sha256 + or json.dumps( + value, + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ) + != self.canonical_receipt_utf8 + ): + raise ValueError("model proxy receipt is invalid") + + def __repr__(self) -> str: + return "ModelProxyResolvedReceipt()" + + @dataclass(frozen=True, slots=True, repr=False) class ModelProxyRequest: - """One strict relay request; repr never exposes its grant or prompt.""" + """One strict session call; repr never exposes its grant, prompt or receipts.""" grant_id: str request_digest: str + call_index: int prompt: str + resolved_receipts: tuple[ModelProxyResolvedReceipt, ...] = () def __post_init__(self) -> None: if ( not _valid_model_grant(self.grant_id) or not _valid_sha256(self.request_digest) + or type(self.call_index) is not int + or not 0 <= self.call_index <= 99 or type(self.prompt) is not str + or not self.prompt + or type(self.resolved_receipts) is not tuple + or len(self.resolved_receipts) > MODEL_PROXY_MAXIMUM_RECEIPTS + or any(type(item) is not ModelProxyResolvedReceipt for item in self.resolved_receipts) + or len({item.receipt_sha256 for item in self.resolved_receipts}) + != len(self.resolved_receipts) ): raise ValueError("model proxy request is invalid") @@ -38,10 +85,14 @@ class ModelProxyReply: output: str | None receipt_digest: str | None failure_code: str | None + call_index: int + session_exhausted: bool def __post_init__(self) -> None: completed = ( self.outcome == "completed" + and type(self.call_index) is int + and 0 <= self.call_index <= 99 and type(self.output) is str and bool(self.output) and _valid_sha256(self.receipt_digest) @@ -49,11 +100,13 @@ def __post_init__(self) -> None: ) unavailable = ( self.outcome == "unavailable" + and type(self.call_index) is int + and 0 <= self.call_index <= 99 and self.output is None and self.receipt_digest is None and _valid_failure_code(self.failure_code) ) - if not completed and not unavailable: + if type(self.session_exhausted) is not bool or (not completed and not unavailable): raise ValueError("model proxy reply is invalid") def __repr__(self) -> str: @@ -67,6 +120,8 @@ def wire_value(self) -> dict[str, str | None]: "output": self.output, "receipt_digest": self.receipt_digest, "failure_code": self.failure_code, + "call_index": self.call_index, + "session_exhausted": self.session_exhausted, } @@ -109,4 +164,5 @@ def _valid_failure_code(value: object) -> bool: "ModelProxyHandler", "ModelProxyReply", "ModelProxyRequest", + "ModelProxyResolvedReceipt", ) diff --git a/backend/app/interfaces/project_guide_runtime.py b/backend/app/interfaces/project_guide_runtime.py index b1db8672d..c213451f7 100644 --- a/backend/app/interfaces/project_guide_runtime.py +++ b/backend/app/interfaces/project_guide_runtime.py @@ -7,6 +7,7 @@ import re from collections.abc import Mapping from typing import TypeAlias +from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, StrictInt, model_validator @@ -87,6 +88,11 @@ class ProjectGuideRuntimeConfiguration(BaseModel): model_provider: str = Field(pattern=r"^[a-z][a-z0-9_.-]{0,63}$") model: str = Field(min_length=1, max_length=200, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$") model_api: str = Field(pattern=r"^[a-z][a-z0-9_]{0,63}$") + model_profile_id: UUID | None = None + model_profile_version: StrictInt | None = Field(default=None, ge=1) + model_profile_digest: str | None = Field( + default=None, pattern=r"^sha256:[0-9a-f]{64}$" + ) adapter_profile: str = Field(min_length=2, max_length=MAXIMUM_ADAPTER_PROFILE_BYTES) instruction_id: str = Field( default="project_guide_compilation", pattern=r"^[a-z][a-z0-9_]{0,63}$" @@ -115,7 +121,20 @@ def validate_snapshot(self) -> ProjectGuideRuntimeConfiguration: raise ValueError("project guide instruction hash mismatch") if self.maximum_document_bytes > self.maximum_total_document_bytes: raise ValueError("document byte limit exceeds the run total") - require_project_guide_inference_identity(self.model_provider, self.model_api, self.model) + uses_inference = require_project_guide_inference_identity( + self.model_provider, self.model_api, self.model + ) + selector = ( + self.model_profile_id, + self.model_profile_version, + self.model_profile_digest, + ) + if any(value is not None for value in selector) and not all( + value is not None for value in selector + ): + raise ValueError("guide runtime model profile selector is incomplete") + if not uses_inference and any(value is not None for value in selector): + raise ValueError("provider-free guide runtime cannot select a model profile") try: profile = json.loads(self.adapter_profile) except (TypeError, ValueError): diff --git a/backend/app/modules/authorization/action_definitions.py b/backend/app/modules/authorization/action_definitions.py new file mode 100644 index 000000000..ee77dddfb --- /dev/null +++ b/backend/app/modules/authorization/action_definitions.py @@ -0,0 +1,727 @@ +"""Closed immutable authorization action definition rows.""" + +from app.modules.authorization import catalogue as _catalogue + +ActionId = _catalogue.ActionId +PermissionId = _catalogue.PermissionId +ActionOwner = _catalogue.ActionOwner +CONTRIBUTOR_WORK_READ_ACTIONS = _catalogue.CONTRIBUTOR_WORK_READ_ACTIONS +PROJECT_GUIDE_READ_ACTIONS = _catalogue.PROJECT_GUIDE_READ_ACTIONS +PROJECT_WORK_READ_ACTIONS = _catalogue.PROJECT_WORK_READ_ACTIONS +_CONTRIBUTION_POLICY_ACTION_IDS = _catalogue._CONTRIBUTION_POLICY_ACTION_IDS +_active = _catalogue._active +_planned = _catalogue._planned + + +ACTION_DEFINITIONS = ( + _active( + ActionId.ACTOR_PROJECT_LIST, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.PILOT_READ_2 + ), + *( + _active(action, PermissionId.SUBMISSION_READ_OWN, ActionOwner.PILOT_READ_2) + for action in sorted(CONTRIBUTOR_WORK_READ_ACTIONS) + ), + *( + _active(a, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.PILOT_READ_3) + for a in sorted(PROJECT_GUIDE_READ_ACTIONS) + ), + *( + _active(a, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_READ_3) + for a in sorted(PROJECT_WORK_READ_ACTIONS) + ), + _active( + ActionId.CONTRIBUTION_POLICY_LIST, + PermissionId.COMPENSATION_POLICY_MANAGE, + ActionOwner.PILOT_READ_3, + ), + _active( + ActionId.CONTRIBUTION_READ_SELF, PermissionId.CONTRIBUTION_READ_SELF, ActionOwner.PILOT_10 + ), + _active( + ActionId.CONTRIBUTION_READ_PROJECT, + PermissionId.CONTRIBUTION_READ_PROJECT, + ActionOwner.PILOT_10, + ), + _active( + ActionId.TASK_POST_SUBMIT_ROUTE, PermissionId.TASK_POST_SUBMIT_ROUTE, ActionOwner.ARCH_04E2 + ), + _active( + ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, + PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, + ActionOwner.ARCH_03C1, + ), + _active(ActionId.TASK_REVISION_EXPIRE, PermissionId.TASK_REVISION_EXPIRE, ActionOwner.PILOT_08), + _active( + ActionId.TASK_REVISION_RECOVER, PermissionId.TASK_REVISION_RECOVER, ActionOwner.PILOT_08 + ), + _active(ActionId.OUTBOX_DISPATCH, PermissionId.OUTBOX_DISPATCH, ActionOwner.AUTH_OUTBOX_01), + _active( + ActionId.ACTOR_PROFILE_READ_SELF, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.AUTH_07B + ), + _active( + ActionId.ACTOR_PROFILE_UPDATE_SELF, + PermissionId.ACTOR_PROFILE_UPDATE_SELF, + ActionOwner.AUTH_07B, + ), + _active( + ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, + PermissionId.ADMIN_ROLE_READ, + ActionOwner.AUTH_08, + ), + _active( + ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ, + PermissionId.ADMIN_ROLE_READ, + ActionOwner.AUTH_08, + ), + _active(ActionId.ADMIN_ROLE_GRANT_LIST, PermissionId.ADMIN_ROLE_READ, ActionOwner.AUTH_08), + _active( + ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ, + PermissionId.ADMIN_ROLE_READ, + ActionOwner.AUTH_08, + ), + _active(ActionId.ADMIN_ROLE_GRANT_ISSUE, PermissionId.ADMIN_ROLE_GRANT, ActionOwner.AUTH_08), + _active(ActionId.ADMIN_ROLE_GRANT_REVOKE, PermissionId.ADMIN_ROLE_REVOKE, ActionOwner.AUTH_08), + _active( + ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, PermissionId.ADMIN_ROLE_GRANT, ActionOwner.AUTH_08 + ), + _active(ActionId.ACTOR_PROFILE_READ, PermissionId.ACTOR_PROFILE_READ_ANY, ActionOwner.AUTH_09C), + _active( + ActionId.ACTOR_PROFILE_SUSPEND, PermissionId.ACTOR_PROFILE_SUSPEND, ActionOwner.AUTH_09D_A + ), + _active( + ActionId.ACTOR_PROFILE_REACTIVATE, + PermissionId.ACTOR_PROFILE_REACTIVATE, + ActionOwner.AUTH_09D_A, + ), + _active( + ActionId.ACTOR_PROFILE_DEACTIVATE, + PermissionId.ACTOR_PROFILE_DEACTIVATE, + ActionOwner.AUTH_09D_A, + ), + _active( + ActionId.ACTOR_IDENTITY_LINK_READ, + PermissionId.ACTOR_IDENTITY_LINK_READ, + ActionOwner.AUTH_09C, + ), + _active( + ActionId.ACTOR_IDENTITY_LINK_REVOKE, + PermissionId.ACTOR_IDENTITY_LINK_REVOKE, + ActionOwner.AUTH_09D_B, + ), + _active( + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + PermissionId.ACTOR_IDENTITY_LINK_REACTIVATE, + ActionOwner.AUTH_09D_B, + ), + _active( + ActionId.ACTOR_SERVICE_PROVISION, PermissionId.ACTOR_SERVICE_PROVISION, ActionOwner.AUTH_09B + ), + _active( + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + PermissionId.PROJECT_ROLE_GRANT_MANAGE, + ActionOwner.AUTH_10B, + ), + _active( + ActionId.PROJECT_ROLE_GRANT_LIST, PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B + ), + _active( + ActionId.PROJECT_ROLE_GRANT_READ, PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B + ), + _active( + ActionId.PROJECT_ROLE_GRANT_ISSUE, + PermissionId.PROJECT_ROLE_GRANT_MANAGE, + ActionOwner.AUTH_10C, + ), + _active( + ActionId.PROJECT_ROLE_GRANT_REVOKE, + PermissionId.PROJECT_ROLE_GRANT_MANAGE, + ActionOwner.AUTH_10C, + ), + _active(ActionId.PROJECT_READ, PermissionId.PROJECT_READ, ActionOwner.AUTH_11B), + _active( + ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, + PermissionId.ACTOR_PROFILE_READ_SELF, + ActionOwner.AUTH_11B, + ), + _active( + ActionId.PROJECT_SETUP_RUN_READ, + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + ActionOwner.AUTH_11C1, + ), + _active( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + ActionOwner.AUTH_11C1, + ), + _active( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + ActionOwner.AUTH_11C1, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C1, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C1, + ), + _active( + ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C2, + ), + _active( + ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C2, + ), + _active(ActionId.PROJECT_ACTIVE_GUIDE_READ, PermissionId.PROJECT_READ, ActionOwner.AUTH_11C2), + _active(ActionId.PROJECT_CREATE, PermissionId.PROJECT_CREATE, ActionOwner.AUTH_12C), + _active(ActionId.PROJECT_GUIDE_CREATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D), + _active(ActionId.PROJECT_GUIDE_UPDATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D), + _active( + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12D, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, + PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, + ActionOwner.AUTH_12I, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, + PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionOwner.AUTH_12I, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionOwner.AUTH_12I, + ), + _active( + ActionId.PROJECT_REVIEW_POLICY_UPDATE, + PermissionId.PROJECT_REVIEW_POLICY_MANAGE, + ActionOwner.XINT_003_02B, + ), + _active( + ActionId.PROJECT_REVISION_POLICY_UPDATE, + PermissionId.PROJECT_REVIEW_POLICY_MANAGE, + ActionOwner.XINT_003_02B, + ), + _active( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + _active( + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + _active( + ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F2, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F3, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F2, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12F4, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_CORRECTION_REQUEST, + PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, + ActionOwner.AUTH_12F4, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F4, + ), + _active( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + _active( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + _active( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + _active( + ActionId.PROJECT_SETUP_RUN_UPDATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12B2 + ), + _active( + ActionId.PROJECT_GUIDE_ACTIVATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12H + ), + _active( + ActionId.TASK_SUBMISSION_LIST, + PermissionId.SUBMISSION_READ_OWN, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.SUBMISSION_READ, PermissionId.SUBMISSION_READ_OWN, ActionOwner.TASK_CHECKER_CLEANUP + ), + _active( + ActionId.SUBMISSION_CHECKER_RUN_LIST, + PermissionId.SUBMISSION_READ_OWN, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.CHECKER_RUN_READ, + PermissionId.SUBMISSION_READ_OWN, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.PROJECT_TASK_SUBMISSION_LIST, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.PROJECT_SUBMISSION_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.PROJECT_SUBMISSION_CHECKER_RUN_LIST, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.PROJECT_CHECKER_RUN_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active(ActionId.TASK_CLAIM, PermissionId.TASK_CLAIM, ActionOwner.TASK_PROJECT_GRANT), + _active(ActionId.TASK_START, PermissionId.TASK_CLAIM, ActionOwner.TASK_PROJECT_GRANT), + _active( + ActionId.TASK_WORK_CONTEXT_READ, + PermissionId.TASK_QUEUE_READ, + ActionOwner.TASK_PROJECT_GRANT, + ), + _active(ActionId.TASK_GUIDE_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.PILOT_13), + _active(ActionId.TASK_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.ARCH_03C5), + _active( + ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, + PermissionId.TASK_QUEUE_READ, + ActionOwner.ARCH_03C5, + ), + _active(ActionId.PROJECT_TASK_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C5), + _active( + ActionId.PROJECT_TASK_LOCKED_CONTEXT_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.ARCH_03C6, + ), + _active( + ActionId.OPERATIONS_TASK_LOCKED_CONTEXT_READ, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.ARCH_03C6, + ), + _active( + ActionId.AUDIT_TASK_LOCKED_CONTEXT_READ, PermissionId.AUDIT_READ, ActionOwner.ARCH_03C6 + ), + _active(ActionId.AUDIT_TASK_EVIDENCE_READ, PermissionId.AUDIT_READ, ActionOwner.ARCH_03C7), + _active( + ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.ARCH_03C5, + ), + _active(ActionId.TASK_QUEUE_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.ARCH_03C4), + _active( + ActionId.PROJECT_TASK_QUEUE_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C4 + ), + _active( + ActionId.OPERATIONS_TASK_QUEUE_READ, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.ARCH_03C4, + ), + _active(ActionId.PROJECT_TASK_CREATE, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), + _active(ActionId.PROJECT_TASK_IMPORT, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_02B), + _active( + ActionId.PROJECT_TASK_IMPORT_BATCH_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.PILOT_02B, + ), + _active( + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_DECLARE, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.PILOT_02A, + ), + _active( + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_UPLOAD, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.PILOT_02A, + ), + _active( + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.PILOT_02A, + ), + _active(ActionId.PROJECT_TASK_SCREEN, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), + _active(ActionId.PROJECT_TASK_RELEASE, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), + _active( + ActionId.PROJECT_TASK_REVISION_REPAIR, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.PILOT_08, + ), + _active( + ActionId.PROJECT_TASK_WORK_CONTEXT_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.TASK_PROJECT_GRANT, + ), + _active( + ActionId.OPERATIONS_TASK_START_OVERRIDE, + PermissionId.OPERATIONS_TASK_START_OVERRIDE, + ActionOwner.TASK_PROJECT_GRANT, + ), + _planned( + ActionId.OPERATIONS_SUBMISSION_GATE_REPAIR, + PermissionId.OPERATIONS_SUBMISSION_GATE_REPAIR, + ActionOwner.AUTH_14, + ), + _active( + ActionId.OPERATIONS_CHECKER_RETRY, + PermissionId.OPERATIONS_CHECKER_RETRY, + ActionOwner.AUTH_14, + ), + _active( + ActionId.OPERATIONS_CHECKER_RETRY_SOURCE_LIST, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.AUTH_14, + ), + _active( + ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_LIST, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.AUTH_14, + ), + _active( + ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_RECONCILE, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_14, + ), + _active( + ActionId.CHECKER_REGISTRY_READ, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.PILOT_04 + ), + _active( + ActionId.CHECKER_REGISTRY_REGISTER, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.PILOT_04, + ), + _active(ActionId.SUBMISSION_CREATE, PermissionId.SUBMISSION_CREATE, ActionOwner.AUTH_14), + _planned(ActionId.REVIEW_QUEUE_READ, PermissionId.REVIEW_QUEUE_READ, ActionOwner.AUTH_REV_05), + _planned( + ActionId.REVIEW_QUEUE_INSPECT, PermissionId.REVIEW_QUEUE_INSPECT, ActionOwner.AUTH_REV_05 + ), + _planned(ActionId.REVIEW_CLAIM, PermissionId.REVIEW_CLAIM, ActionOwner.AUTH_REV_06), + _planned(ActionId.REVIEW_RELEASE, PermissionId.REVIEW_RELEASE, ActionOwner.AUTH_REV_06), + _planned( + ActionId.REVIEW_DECLINE_PREFERENCE, + PermissionId.REVIEW_DECLINE_PREFERENCE, + ActionOwner.AUTH_REV_06, + ), + _planned( + ActionId.REVIEW_PREFERENCE_EXPIRY_RUN, + PermissionId.OPERATIONS_TIMER_RUN, + ActionOwner.AUTH_REV_06, + ), + _planned( + ActionId.REVIEW_LEASE_EXPIRY_RUN, PermissionId.OPERATIONS_TIMER_RUN, ActionOwner.AUTH_REV_06 + ), + _planned( + ActionId.REVIEW_CONTEXT_READ, + PermissionId.SUBMISSION_READ_FOR_REVIEW, + ActionOwner.AUTH_REV_07, + ), + _planned(ActionId.REVIEW_CHAIN_READ, PermissionId.REVIEW_CHAIN_READ, ActionOwner.AUTH_REV_07), + _planned( + ActionId.REVIEW_FINDING_EVIDENCE_INGEST, + PermissionId.REVIEW_DECISION, + ActionOwner.AUTH_REV_07, + ), + _planned(ActionId.REVIEW_DECISION, PermissionId.REVIEW_DECISION, ActionOwner.AUTH_REV_08), + _planned( + ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, + PermissionId.SUBMISSION_CREATE, + ActionOwner.AUTH_REV_09A, + ), + _planned( + ActionId.REVIEW_LEASE_FORCE_RELEASE, + PermissionId.REVIEW_LEASE_FORCE_RELEASE, + ActionOwner.AUTH_REV_11, + ), + _planned( + ActionId.REVIEW_QUEUE_ROUTING_OVERRIDE, + PermissionId.REVIEW_QUEUE_OVERRIDE, + ActionOwner.AUTH_REV_11, + ), + _planned( + ActionId.REVIEW_QUEUE_ROUTING_CORRECT, + PermissionId.REVIEW_QUEUE_OVERRIDE, + ActionOwner.AUTH_REV_11, + ), + _planned( + ActionId.REVIEW_QUEUE_CLOSE, PermissionId.REVIEW_QUEUE_OVERRIDE, ActionOwner.AUTH_REV_11 + ), + _planned( + ActionId.REVIEW_RECONCILE_RUN, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_REV_11, + ), + _planned( + ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_REV_12, + ), + _planned( + ActionId.REVIEW_PROJECTION_REBUILD, + PermissionId.OPERATIONS_PROJECTION_REBUILD, + ActionOwner.AUTH_REV_12, + ), + _planned( + ActionId.REVIEW_REVISION_CONTEXT_REPAIR, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.XINT_003_08A, + ), + _planned( + ActionId.REVIEW_REVISION_CONTEXT_LEGACY_CLOSE, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.XINT_003_08A, + ), + _active( + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.XINT_003_08B, + ), + _active( + ActionId.ARTIFACT_BINDING_READ, + PermissionId.ARTIFACT_BINDING_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_REPLICA_READ, + PermissionId.ARTIFACT_REPLICA_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_RECEIPT_READ, + PermissionId.ARTIFACT_RECEIPT_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_VERIFICATION_JOB_READ, + PermissionId.ARTIFACT_VERIFICATION_JOB_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_VERIFICATION_JOB_RETRY, + PermissionId.ARTIFACT_VERIFICATION_JOB_RETRY, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_RECOVERY_ATTEMPT_READ, + PermissionId.ARTIFACT_RECOVERY_ATTEMPT_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_AUDIT_READ, + PermissionId.ARTIFACT_AUDIT_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.OPERATIONS_ARTIFACT_STORAGE_ADMISSION_READ, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + PermissionId.ARTIFACT_GUIDE_SOURCE_INGEST, + ActionOwner.XINT_002_04A, + ), + _active( + ActionId.ARTIFACT_GUIDE_SOURCE_READ, + PermissionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionOwner.XINT_002_04B, + ), + _active( + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, + PermissionId.SUBMISSION_CREATE, + ActionOwner.XINT_002_05A, + ), + _active( + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.AUTH_ART_05, + ), + _active( + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.AUTH_ART_06B, + ), + _active( + ActionId.ARTIFACT_VERIFICATION_EXECUTE, + PermissionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + _active( + ActionId.ARTIFACT_PENDING_WORK_SCAN, + PermissionId.ARTIFACT_PENDING_WORK_SCAN, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + _active( + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + _active( + ActionId.CHECKER_PRE_SUBMIT_EXECUTE, + PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, + ActionOwner.PILOT_04, + ), + _active( + ActionId.CHECKER_PRE_SUBMIT_FINALIZE, + PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, + ActionOwner.PILOT_04, + ), + _active( + ActionId.CHECKER_POST_SUBMIT_EXECUTE, + PermissionId.CHECKER_POST_SUBMIT_EXECUTE, + ActionOwner.ARCH_04D2, + ), + _active( + ActionId.CHECKER_POST_SUBMIT_FINALIZE, + PermissionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionOwner.ARCH_04D2, + ), + _active( + ActionId.CHECKER_MODEL_CALL_PREPARE, + PermissionId.CHECKER_MODEL_CALL_PREPARE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_DISPATCH, + PermissionId.CHECKER_MODEL_CALL_DISPATCH, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_RECOVER, + PermissionId.CHECKER_MODEL_CALL_RECOVER, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_OBSERVE, + PermissionId.CHECKER_MODEL_CALL_OBSERVE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_COMPLETE, + PermissionId.CHECKER_MODEL_CALL_COMPLETE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_GRANT, + PermissionId.CHECKER_MODEL_CALL_GRANT, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_MODEL_PROFILE_PROVISION, + PermissionId.PROJECT_MODEL_PROFILE_MANAGE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_MODEL_BUDGET_CONFIGURE, + PermissionId.PROJECT_MODEL_BUDGET_MANAGE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, + PermissionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, + ActionOwner.XINT_002_06A, + ), + _active( + ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, + PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, + ActionOwner.AUTH_ART_06A, + ), + _active( + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + ActionOwner.AUTH_ART_06B, + ), + _planned( + ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, + PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, + ActionOwner.XINT_002_07, + ), + _planned( + ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE, + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.XINT_002_07, + ), + *( + _active(action, PermissionId.COMPENSATION_ADAPTER_BINDING_MANAGE, ActionOwner.ARCH_CP01A) + for action in ( + ActionId.COMPENSATION_ADAPTER_BINDING_READ, + ActionId.COMPENSATION_ADAPTER_BINDING_CREATE, + ActionId.COMPENSATION_ADAPTER_BINDING_SUSPEND, + ActionId.COMPENSATION_ADAPTER_BINDING_RESUME, + ) + ), + *( + _active(action, PermissionId.COMPENSATION_POLICY_MANAGE, ActionOwner.ARCH_CP01B) + for action in _CONTRIBUTION_POLICY_ACTION_IDS + ), + _active( + ActionId.CONTRIBUTION_COMPENSATION_UNIT_CREATE, + PermissionId.COMPENSATION_POLICY_MANAGE, + ActionOwner.PROJECT_POINTS_UNIT_CREATION, + ), +) diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index d9b2cc367..bda4b8101 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -499,718 +499,7 @@ def _active( ) -ACTION_DEFINITIONS = ( - _active( - ActionId.ACTOR_PROJECT_LIST, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.PILOT_READ_2 - ), - *( - _active(action, PermissionId.SUBMISSION_READ_OWN, ActionOwner.PILOT_READ_2) - for action in sorted(CONTRIBUTOR_WORK_READ_ACTIONS) - ), - *( - _active(a, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.PILOT_READ_3) - for a in sorted(PROJECT_GUIDE_READ_ACTIONS) - ), - *( - _active(a, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_READ_3) - for a in sorted(PROJECT_WORK_READ_ACTIONS) - ), - _active( - ActionId.CONTRIBUTION_POLICY_LIST, - PermissionId.COMPENSATION_POLICY_MANAGE, - ActionOwner.PILOT_READ_3, - ), - _active( - ActionId.CONTRIBUTION_READ_SELF, PermissionId.CONTRIBUTION_READ_SELF, ActionOwner.PILOT_10 - ), - _active( - ActionId.CONTRIBUTION_READ_PROJECT, - PermissionId.CONTRIBUTION_READ_PROJECT, - ActionOwner.PILOT_10, - ), - _active( - ActionId.TASK_POST_SUBMIT_ROUTE, PermissionId.TASK_POST_SUBMIT_ROUTE, ActionOwner.ARCH_04E2 - ), - _active( - ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, - PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, - ActionOwner.ARCH_03C1, - ), - _active(ActionId.TASK_REVISION_EXPIRE, PermissionId.TASK_REVISION_EXPIRE, ActionOwner.PILOT_08), - _active( - ActionId.TASK_REVISION_RECOVER, PermissionId.TASK_REVISION_RECOVER, ActionOwner.PILOT_08 - ), - _active(ActionId.OUTBOX_DISPATCH, PermissionId.OUTBOX_DISPATCH, ActionOwner.AUTH_OUTBOX_01), - _active( - ActionId.ACTOR_PROFILE_READ_SELF, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.AUTH_07B - ), - _active( - ActionId.ACTOR_PROFILE_UPDATE_SELF, - PermissionId.ACTOR_PROFILE_UPDATE_SELF, - ActionOwner.AUTH_07B, - ), - _active( - ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, - PermissionId.ADMIN_ROLE_READ, - ActionOwner.AUTH_08, - ), - _active( - ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ, - PermissionId.ADMIN_ROLE_READ, - ActionOwner.AUTH_08, - ), - _active(ActionId.ADMIN_ROLE_GRANT_LIST, PermissionId.ADMIN_ROLE_READ, ActionOwner.AUTH_08), - _active( - ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ, - PermissionId.ADMIN_ROLE_READ, - ActionOwner.AUTH_08, - ), - _active(ActionId.ADMIN_ROLE_GRANT_ISSUE, PermissionId.ADMIN_ROLE_GRANT, ActionOwner.AUTH_08), - _active(ActionId.ADMIN_ROLE_GRANT_REVOKE, PermissionId.ADMIN_ROLE_REVOKE, ActionOwner.AUTH_08), - _active( - ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, PermissionId.ADMIN_ROLE_GRANT, ActionOwner.AUTH_08 - ), - _active(ActionId.ACTOR_PROFILE_READ, PermissionId.ACTOR_PROFILE_READ_ANY, ActionOwner.AUTH_09C), - _active( - ActionId.ACTOR_PROFILE_SUSPEND, PermissionId.ACTOR_PROFILE_SUSPEND, ActionOwner.AUTH_09D_A - ), - _active( - ActionId.ACTOR_PROFILE_REACTIVATE, - PermissionId.ACTOR_PROFILE_REACTIVATE, - ActionOwner.AUTH_09D_A, - ), - _active( - ActionId.ACTOR_PROFILE_DEACTIVATE, - PermissionId.ACTOR_PROFILE_DEACTIVATE, - ActionOwner.AUTH_09D_A, - ), - _active( - ActionId.ACTOR_IDENTITY_LINK_READ, - PermissionId.ACTOR_IDENTITY_LINK_READ, - ActionOwner.AUTH_09C, - ), - _active( - ActionId.ACTOR_IDENTITY_LINK_REVOKE, - PermissionId.ACTOR_IDENTITY_LINK_REVOKE, - ActionOwner.AUTH_09D_B, - ), - _active( - ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, - PermissionId.ACTOR_IDENTITY_LINK_REACTIVATE, - ActionOwner.AUTH_09D_B, - ), - _active( - ActionId.ACTOR_SERVICE_PROVISION, PermissionId.ACTOR_SERVICE_PROVISION, ActionOwner.AUTH_09B - ), - _active( - ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, - PermissionId.PROJECT_ROLE_GRANT_MANAGE, - ActionOwner.AUTH_10B, - ), - _active( - ActionId.PROJECT_ROLE_GRANT_LIST, PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B - ), - _active( - ActionId.PROJECT_ROLE_GRANT_READ, PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B - ), - _active( - ActionId.PROJECT_ROLE_GRANT_ISSUE, - PermissionId.PROJECT_ROLE_GRANT_MANAGE, - ActionOwner.AUTH_10C, - ), - _active( - ActionId.PROJECT_ROLE_GRANT_REVOKE, - PermissionId.PROJECT_ROLE_GRANT_MANAGE, - ActionOwner.AUTH_10C, - ), - _active(ActionId.PROJECT_READ, PermissionId.PROJECT_READ, ActionOwner.AUTH_11B), - _active( - ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, - PermissionId.ACTOR_PROFILE_READ_SELF, - ActionOwner.AUTH_11B, - ), - _active( - ActionId.PROJECT_SETUP_RUN_READ, - PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, - ActionOwner.AUTH_11C1, - ), - _active( - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, - PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, - ActionOwner.AUTH_11C1, - ), - _active( - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, - PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, - ActionOwner.AUTH_11C1, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, - PermissionId.PROJECT_EFFECTIVE_POLICY_READ, - ActionOwner.AUTH_11C1, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, - PermissionId.PROJECT_EFFECTIVE_POLICY_READ, - ActionOwner.AUTH_11C1, - ), - _active( - ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, - PermissionId.PROJECT_EFFECTIVE_POLICY_READ, - ActionOwner.AUTH_11C2, - ), - _active( - ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, - PermissionId.PROJECT_EFFECTIVE_POLICY_READ, - ActionOwner.AUTH_11C2, - ), - _active(ActionId.PROJECT_ACTIVE_GUIDE_READ, PermissionId.PROJECT_READ, ActionOwner.AUTH_11C2), - _active(ActionId.PROJECT_CREATE, PermissionId.PROJECT_CREATE, ActionOwner.AUTH_12C), - _active(ActionId.PROJECT_GUIDE_CREATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D), - _active(ActionId.PROJECT_GUIDE_UPDATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D), - _active( - ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, - PermissionId.PROJECT_GUIDE_MANAGE, - ActionOwner.AUTH_12D, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, - PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, - ActionOwner.AUTH_12I, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, - PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, - ActionOwner.AUTH_12I, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, - PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, - ActionOwner.AUTH_12I, - ), - _active( - ActionId.PROJECT_REVIEW_POLICY_UPDATE, - PermissionId.PROJECT_REVIEW_POLICY_MANAGE, - ActionOwner.XINT_003_02B, - ), - _active( - ActionId.PROJECT_REVISION_POLICY_UPDATE, - PermissionId.PROJECT_REVIEW_POLICY_MANAGE, - ActionOwner.XINT_003_02B, - ), - _active( - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, - PermissionId.PROJECT_GUIDE_MANAGE, - ActionOwner.AUTH_12E, - ), - _active( - ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, - PermissionId.PROJECT_GUIDE_MANAGE, - ActionOwner.AUTH_12E, - ), - _active( - ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, - PermissionId.PROJECT_GUIDE_MANAGE, - ActionOwner.AUTH_12E, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F2, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F3, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F2, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ, - PermissionId.PROJECT_GUIDE_MANAGE, - ActionOwner.AUTH_12F4, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_CORRECTION_REQUEST, - PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, - ActionOwner.AUTH_12F4, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F4, - ), - _active( - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12G, - ), - _active( - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12G, - ), - _active( - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12G, - ), - _active( - ActionId.PROJECT_SETUP_RUN_UPDATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12B2 - ), - _active( - ActionId.PROJECT_GUIDE_ACTIVATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12H - ), - _active( - ActionId.TASK_SUBMISSION_LIST, - PermissionId.SUBMISSION_READ_OWN, - ActionOwner.TASK_CHECKER_CLEANUP, - ), - _active( - ActionId.SUBMISSION_READ, PermissionId.SUBMISSION_READ_OWN, ActionOwner.TASK_CHECKER_CLEANUP - ), - _active( - ActionId.SUBMISSION_CHECKER_RUN_LIST, - PermissionId.SUBMISSION_READ_OWN, - ActionOwner.TASK_CHECKER_CLEANUP, - ), - _active( - ActionId.CHECKER_RUN_READ, - PermissionId.SUBMISSION_READ_OWN, - ActionOwner.TASK_CHECKER_CLEANUP, - ), - _active( - ActionId.PROJECT_TASK_SUBMISSION_LIST, - PermissionId.PROJECT_TASK_MANAGE, - ActionOwner.TASK_CHECKER_CLEANUP, - ), - _active( - ActionId.PROJECT_SUBMISSION_READ, - PermissionId.PROJECT_TASK_MANAGE, - ActionOwner.TASK_CHECKER_CLEANUP, - ), - _active( - ActionId.PROJECT_SUBMISSION_CHECKER_RUN_LIST, - PermissionId.PROJECT_TASK_MANAGE, - ActionOwner.TASK_CHECKER_CLEANUP, - ), - _active( - ActionId.PROJECT_CHECKER_RUN_READ, - PermissionId.PROJECT_TASK_MANAGE, - ActionOwner.TASK_CHECKER_CLEANUP, - ), - _active(ActionId.TASK_CLAIM, PermissionId.TASK_CLAIM, ActionOwner.TASK_PROJECT_GRANT), - _active(ActionId.TASK_START, PermissionId.TASK_CLAIM, ActionOwner.TASK_PROJECT_GRANT), - _active( - ActionId.TASK_WORK_CONTEXT_READ, - PermissionId.TASK_QUEUE_READ, - ActionOwner.TASK_PROJECT_GRANT, - ), - _active(ActionId.TASK_GUIDE_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.PILOT_13), - _active(ActionId.TASK_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.ARCH_03C5), - _active( - ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, - PermissionId.TASK_QUEUE_READ, - ActionOwner.ARCH_03C5, - ), - _active(ActionId.PROJECT_TASK_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C5), - _active( - ActionId.PROJECT_TASK_LOCKED_CONTEXT_READ, - PermissionId.PROJECT_TASK_MANAGE, - ActionOwner.ARCH_03C6, - ), - _active( - ActionId.OPERATIONS_TASK_LOCKED_CONTEXT_READ, - PermissionId.OPERATIONS_STATUS_READ, - ActionOwner.ARCH_03C6, - ), - _active( - ActionId.AUDIT_TASK_LOCKED_CONTEXT_READ, PermissionId.AUDIT_READ, ActionOwner.ARCH_03C6 - ), - _active(ActionId.AUDIT_TASK_EVIDENCE_READ, PermissionId.AUDIT_READ, ActionOwner.ARCH_03C7), - _active( - ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, - PermissionId.PROJECT_TASK_MANAGE, - ActionOwner.ARCH_03C5, - ), - _active(ActionId.TASK_QUEUE_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.ARCH_03C4), - _active( - ActionId.PROJECT_TASK_QUEUE_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C4 - ), - _active( - ActionId.OPERATIONS_TASK_QUEUE_READ, - PermissionId.OPERATIONS_STATUS_READ, - ActionOwner.ARCH_03C4, - ), - _active(ActionId.PROJECT_TASK_CREATE, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), - _active(ActionId.PROJECT_TASK_IMPORT, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_02B), - _active( - ActionId.PROJECT_TASK_IMPORT_BATCH_READ, - PermissionId.PROJECT_TASK_MANAGE, - ActionOwner.PILOT_02B, - ), - _active( - ActionId.ARTIFACT_TASK_IMPORT_SOURCE_DECLARE, - PermissionId.PROJECT_TASK_MANAGE, - ActionOwner.PILOT_02A, - ), - _active( - ActionId.ARTIFACT_TASK_IMPORT_SOURCE_UPLOAD, - PermissionId.PROJECT_TASK_MANAGE, - ActionOwner.PILOT_02A, - ), - _active( - ActionId.ARTIFACT_TASK_IMPORT_SOURCE_READ, - PermissionId.PROJECT_TASK_MANAGE, - ActionOwner.PILOT_02A, - ), - _active(ActionId.PROJECT_TASK_SCREEN, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), - _active(ActionId.PROJECT_TASK_RELEASE, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), - _active( - ActionId.PROJECT_TASK_REVISION_REPAIR, - PermissionId.PROJECT_TASK_MANAGE, - ActionOwner.PILOT_08, - ), - _active( - ActionId.PROJECT_TASK_WORK_CONTEXT_READ, - PermissionId.PROJECT_TASK_MANAGE, - ActionOwner.TASK_PROJECT_GRANT, - ), - _active( - ActionId.OPERATIONS_TASK_START_OVERRIDE, - PermissionId.OPERATIONS_TASK_START_OVERRIDE, - ActionOwner.TASK_PROJECT_GRANT, - ), - _planned( - ActionId.OPERATIONS_SUBMISSION_GATE_REPAIR, - PermissionId.OPERATIONS_SUBMISSION_GATE_REPAIR, - ActionOwner.AUTH_14, - ), - _active( - ActionId.OPERATIONS_CHECKER_RETRY, - PermissionId.OPERATIONS_CHECKER_RETRY, - ActionOwner.AUTH_14, - ), - _active( - ActionId.OPERATIONS_CHECKER_RETRY_SOURCE_LIST, - PermissionId.OPERATIONS_STATUS_READ, - ActionOwner.AUTH_14, - ), - _active( - ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_LIST, - PermissionId.OPERATIONS_STATUS_READ, - ActionOwner.AUTH_14, - ), - _active( - ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_RECONCILE, - PermissionId.OPERATIONS_RECONCILE_RUN, - ActionOwner.AUTH_14, - ), - _active( - ActionId.CHECKER_REGISTRY_READ, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.PILOT_04 - ), - _active( - ActionId.CHECKER_REGISTRY_REGISTER, - PermissionId.OPERATIONS_RECONCILE_RUN, - ActionOwner.PILOT_04, - ), - _active(ActionId.SUBMISSION_CREATE, PermissionId.SUBMISSION_CREATE, ActionOwner.AUTH_14), - _planned(ActionId.REVIEW_QUEUE_READ, PermissionId.REVIEW_QUEUE_READ, ActionOwner.AUTH_REV_05), - _planned( - ActionId.REVIEW_QUEUE_INSPECT, PermissionId.REVIEW_QUEUE_INSPECT, ActionOwner.AUTH_REV_05 - ), - _planned(ActionId.REVIEW_CLAIM, PermissionId.REVIEW_CLAIM, ActionOwner.AUTH_REV_06), - _planned(ActionId.REVIEW_RELEASE, PermissionId.REVIEW_RELEASE, ActionOwner.AUTH_REV_06), - _planned( - ActionId.REVIEW_DECLINE_PREFERENCE, - PermissionId.REVIEW_DECLINE_PREFERENCE, - ActionOwner.AUTH_REV_06, - ), - _planned( - ActionId.REVIEW_PREFERENCE_EXPIRY_RUN, - PermissionId.OPERATIONS_TIMER_RUN, - ActionOwner.AUTH_REV_06, - ), - _planned( - ActionId.REVIEW_LEASE_EXPIRY_RUN, PermissionId.OPERATIONS_TIMER_RUN, ActionOwner.AUTH_REV_06 - ), - _planned( - ActionId.REVIEW_CONTEXT_READ, - PermissionId.SUBMISSION_READ_FOR_REVIEW, - ActionOwner.AUTH_REV_07, - ), - _planned(ActionId.REVIEW_CHAIN_READ, PermissionId.REVIEW_CHAIN_READ, ActionOwner.AUTH_REV_07), - _planned( - ActionId.REVIEW_FINDING_EVIDENCE_INGEST, - PermissionId.REVIEW_DECISION, - ActionOwner.AUTH_REV_07, - ), - _planned(ActionId.REVIEW_DECISION, PermissionId.REVIEW_DECISION, ActionOwner.AUTH_REV_08), - _planned( - ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, - PermissionId.SUBMISSION_CREATE, - ActionOwner.AUTH_REV_09A, - ), - _planned( - ActionId.REVIEW_LEASE_FORCE_RELEASE, - PermissionId.REVIEW_LEASE_FORCE_RELEASE, - ActionOwner.AUTH_REV_11, - ), - _planned( - ActionId.REVIEW_QUEUE_ROUTING_OVERRIDE, - PermissionId.REVIEW_QUEUE_OVERRIDE, - ActionOwner.AUTH_REV_11, - ), - _planned( - ActionId.REVIEW_QUEUE_ROUTING_CORRECT, - PermissionId.REVIEW_QUEUE_OVERRIDE, - ActionOwner.AUTH_REV_11, - ), - _planned( - ActionId.REVIEW_QUEUE_CLOSE, PermissionId.REVIEW_QUEUE_OVERRIDE, ActionOwner.AUTH_REV_11 - ), - _planned( - ActionId.REVIEW_RECONCILE_RUN, - PermissionId.OPERATIONS_RECONCILE_RUN, - ActionOwner.AUTH_REV_11, - ), - _planned( - ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE, - PermissionId.OPERATIONS_RECONCILE_RUN, - ActionOwner.AUTH_REV_12, - ), - _planned( - ActionId.REVIEW_PROJECTION_REBUILD, - PermissionId.OPERATIONS_PROJECTION_REBUILD, - ActionOwner.AUTH_REV_12, - ), - _planned( - ActionId.REVIEW_REVISION_CONTEXT_REPAIR, - PermissionId.PROJECT_TASK_MANAGE, - ActionOwner.XINT_003_08A, - ), - _planned( - ActionId.REVIEW_REVISION_CONTEXT_LEGACY_CLOSE, - PermissionId.OPERATIONS_RECONCILE_RUN, - ActionOwner.XINT_003_08A, - ), - _active( - ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, - PermissionId.OPERATIONS_RECONCILE_RUN, - ActionOwner.XINT_003_08B, - ), - _active( - ActionId.ARTIFACT_BINDING_READ, - PermissionId.ARTIFACT_BINDING_READ, - ActionOwner.AUTH_ART_02D_OPERATOR, - ), - _active( - ActionId.ARTIFACT_REPLICA_READ, - PermissionId.ARTIFACT_REPLICA_READ, - ActionOwner.AUTH_ART_02D_OPERATOR, - ), - _active( - ActionId.ARTIFACT_RECEIPT_READ, - PermissionId.ARTIFACT_RECEIPT_READ, - ActionOwner.AUTH_ART_02D_OPERATOR, - ), - _active( - ActionId.ARTIFACT_VERIFICATION_JOB_READ, - PermissionId.ARTIFACT_VERIFICATION_JOB_READ, - ActionOwner.AUTH_ART_02D_OPERATOR, - ), - _active( - ActionId.ARTIFACT_VERIFICATION_JOB_RETRY, - PermissionId.ARTIFACT_VERIFICATION_JOB_RETRY, - ActionOwner.AUTH_ART_02D_OPERATOR, - ), - _active( - ActionId.ARTIFACT_RECOVERY_ATTEMPT_READ, - PermissionId.ARTIFACT_RECOVERY_ATTEMPT_READ, - ActionOwner.AUTH_ART_02D_OPERATOR, - ), - _active( - ActionId.ARTIFACT_AUDIT_READ, - PermissionId.ARTIFACT_AUDIT_READ, - ActionOwner.AUTH_ART_02D_OPERATOR, - ), - _active( - ActionId.OPERATIONS_ARTIFACT_STORAGE_ADMISSION_READ, - PermissionId.OPERATIONS_STATUS_READ, - ActionOwner.AUTH_ART_02D_OPERATOR, - ), - _active( - ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, - PermissionId.ARTIFACT_GUIDE_SOURCE_INGEST, - ActionOwner.XINT_002_04A, - ), - _active( - ActionId.ARTIFACT_GUIDE_SOURCE_READ, - PermissionId.ARTIFACT_GUIDE_SOURCE_READ, - ActionOwner.XINT_002_04B, - ), - _active( - ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, - PermissionId.SUBMISSION_CREATE, - ActionOwner.XINT_002_05A, - ), - _active( - ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, - PermissionId.ARTIFACT_BINDING_CREATE, - ActionOwner.AUTH_ART_05, - ), - _active( - ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, - PermissionId.ARTIFACT_BINDING_CREATE, - ActionOwner.AUTH_ART_06B, - ), - _active( - ActionId.ARTIFACT_VERIFICATION_EXECUTE, - PermissionId.ARTIFACT_VERIFICATION_EXECUTE, - ActionOwner.AUTH_ART_02D_INTERNAL, - ), - _active( - ActionId.ARTIFACT_PENDING_WORK_SCAN, - PermissionId.ARTIFACT_PENDING_WORK_SCAN, - ActionOwner.AUTH_ART_02D_INTERNAL, - ), - _active( - ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, - PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, - ActionOwner.AUTH_ART_02D_INTERNAL, - ), - _active( - ActionId.CHECKER_PRE_SUBMIT_EXECUTE, - PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, - ActionOwner.PILOT_04, - ), - _active( - ActionId.CHECKER_PRE_SUBMIT_FINALIZE, - PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, - ActionOwner.PILOT_04, - ), - _active( - ActionId.CHECKER_POST_SUBMIT_EXECUTE, - PermissionId.CHECKER_POST_SUBMIT_EXECUTE, - ActionOwner.ARCH_04D2, - ), - _active( - ActionId.CHECKER_POST_SUBMIT_FINALIZE, - PermissionId.CHECKER_POST_SUBMIT_FINALIZE, - ActionOwner.ARCH_04D2, - ), - _active( - ActionId.CHECKER_MODEL_CALL_PREPARE, - PermissionId.CHECKER_MODEL_CALL_PREPARE, - ActionOwner.PILOT_05, - ), - _active( - ActionId.CHECKER_MODEL_CALL_DISPATCH, - PermissionId.CHECKER_MODEL_CALL_DISPATCH, - ActionOwner.PILOT_05, - ), - _active( - ActionId.CHECKER_MODEL_CALL_RECOVER, - PermissionId.CHECKER_MODEL_CALL_RECOVER, - ActionOwner.PILOT_05, - ), - _active( - ActionId.CHECKER_MODEL_CALL_OBSERVE, - PermissionId.CHECKER_MODEL_CALL_OBSERVE, - ActionOwner.PILOT_05, - ), - _active( - ActionId.CHECKER_MODEL_CALL_COMPLETE, - PermissionId.CHECKER_MODEL_CALL_COMPLETE, - ActionOwner.PILOT_05, - ), - _active( - ActionId.CHECKER_MODEL_CALL_GRANT, - PermissionId.CHECKER_MODEL_CALL_GRANT, - ActionOwner.PILOT_05, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, - PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, - ActionOwner.PILOT_05, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, - PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, - ActionOwner.PILOT_05, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, - PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, - ActionOwner.PILOT_05, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, - PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, - ActionOwner.PILOT_05, - ), - _active( - ActionId.PROJECT_MODEL_PROFILE_PROVISION, - PermissionId.PROJECT_MODEL_PROFILE_MANAGE, - ActionOwner.PILOT_05, - ), - _active( - ActionId.PROJECT_MODEL_BUDGET_CONFIGURE, - PermissionId.PROJECT_MODEL_BUDGET_MANAGE, - ActionOwner.PILOT_05, - ), - _active( - ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, - PermissionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, - ActionOwner.PILOT_05, - ), - _active( - ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, - PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, - ActionOwner.XINT_002_06A, - ), - _active( - ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, - PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, - ActionOwner.AUTH_ART_06A, - ), - _active( - ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, - PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, - ActionOwner.AUTH_ART_06B, - ), - _planned( - ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, - PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, - ActionOwner.XINT_002_07, - ), - _planned( - ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE, - PermissionId.ARTIFACT_BINDING_CREATE, - ActionOwner.XINT_002_07, - ), - *( - _active(action, PermissionId.COMPENSATION_ADAPTER_BINDING_MANAGE, ActionOwner.ARCH_CP01A) - for action in ( - ActionId.COMPENSATION_ADAPTER_BINDING_READ, - ActionId.COMPENSATION_ADAPTER_BINDING_CREATE, - ActionId.COMPENSATION_ADAPTER_BINDING_SUSPEND, - ActionId.COMPENSATION_ADAPTER_BINDING_RESUME, - ) - ), - *( - _active(action, PermissionId.COMPENSATION_POLICY_MANAGE, ActionOwner.ARCH_CP01B) - for action in _CONTRIBUTION_POLICY_ACTION_IDS - ), - _active( - ActionId.CONTRIBUTION_COMPENSATION_UNIT_CREATE, - PermissionId.COMPENSATION_POLICY_MANAGE, - ActionOwner.PROJECT_POINTS_UNIT_CREATION, - ), -) +from app.modules.authorization.action_definitions import ACTION_DEFINITIONS # noqa: E402 PERMISSION_IDS = frozenset(PermissionId) ACTION_IDS = frozenset(ActionId) @@ -1662,166 +951,168 @@ def _index_actions( } +_SERVICE_ACTION_METADATA = { + ActionId.TASK_POST_SUBMIT_ROUTE: ( + PermissionId.TASK_POST_SUBMIT_ROUTE, + ActionOwner.ARCH_04E2, + ), + ActionId.CHECKER_PRE_SUBMIT_EXECUTE: ( + PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, + ActionOwner.PILOT_04, + ), + ActionId.CHECKER_PRE_SUBMIT_FINALIZE: ( + PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, + ActionOwner.PILOT_04, + ), + ActionId.CHECKER_POST_SUBMIT_EXECUTE: ( + PermissionId.CHECKER_POST_SUBMIT_EXECUTE, + ActionOwner.ARCH_04D2, + ), + ActionId.CHECKER_POST_SUBMIT_FINALIZE: ( + PermissionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionOwner.ARCH_04D2, + ), + ActionId.CHECKER_MODEL_CALL_PREPARE: ( + PermissionId.CHECKER_MODEL_CALL_PREPARE, + ActionOwner.PILOT_05, + ), + ActionId.CHECKER_MODEL_CALL_DISPATCH: ( + PermissionId.CHECKER_MODEL_CALL_DISPATCH, + ActionOwner.PILOT_05, + ), + ActionId.CHECKER_MODEL_CALL_RECOVER: ( + PermissionId.CHECKER_MODEL_CALL_RECOVER, + ActionOwner.PILOT_05, + ), + ActionId.CHECKER_MODEL_CALL_OBSERVE: ( + PermissionId.CHECKER_MODEL_CALL_OBSERVE, + ActionOwner.PILOT_05, + ), + ActionId.CHECKER_MODEL_CALL_COMPLETE: ( + PermissionId.CHECKER_MODEL_CALL_COMPLETE, + ActionOwner.PILOT_05, + ), + ActionId.CHECKER_MODEL_CALL_GRANT: ( + PermissionId.CHECKER_MODEL_CALL_GRANT, + ActionOwner.PILOT_05, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionOwner.PILOT_05, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionOwner.PILOT_05, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionOwner.PILOT_05, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + ActionOwner.PILOT_05, + ), + ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE: ( + PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, + ActionOwner.ARCH_03C1, + ), + ActionId.TASK_REVISION_EXPIRE: (PermissionId.TASK_REVISION_EXPIRE, ActionOwner.PILOT_08), + ActionId.TASK_REVISION_RECOVER: (PermissionId.TASK_REVISION_RECOVER, ActionOwner.PILOT_08), + ActionId.OUTBOX_DISPATCH: (PermissionId.OUTBOX_DISPATCH, ActionOwner.AUTH_OUTBOX_01), + ActionId.ARTIFACT_VERIFICATION_EXECUTE: ( + PermissionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ( + PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + ActionId.ARTIFACT_PENDING_WORK_SCAN: ( + PermissionId.ARTIFACT_PENDING_WORK_SCAN, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE: ( + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.AUTH_ART_05, + ), + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE: ( + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.AUTH_ART_06B, + ), + ActionId.ARTIFACT_GUIDE_SOURCE_READ: ( + PermissionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionOwner.XINT_002_04B, + ), + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( + PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, + ActionOwner.XINT_002_06A, + ), + ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( + PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, + ActionOwner.AUTH_ART_06A, + ), + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE: ( + PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + ActionOwner.AUTH_ART_06B, + ), + ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE: ( + PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, + ActionOwner.XINT_002_07, + ), + ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE: ( + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.XINT_002_07, + ), + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: ( + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC: ( + PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionOwner.AUTH_12I, + ), + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionOwner.AUTH_12I, + ), + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: ( + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F3, + ), + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: ( + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + ActionId.PROJECT_SETUP_RUN_UPDATE: ( + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12B2, + ), + ActionId.REVIEW_PREFERENCE_EXPIRY_RUN: ( + PermissionId.OPERATIONS_TIMER_RUN, + ActionOwner.AUTH_REV_06, + ), + ActionId.REVIEW_LEASE_EXPIRY_RUN: ( + PermissionId.OPERATIONS_TIMER_RUN, + ActionOwner.AUTH_REV_06, + ), + ActionId.REVIEW_RECONCILE_RUN: ( + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_REV_11, + ), + ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE: ( + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_REV_12, + ), + ActionId.REVIEW_PROJECTION_REBUILD: ( + PermissionId.OPERATIONS_PROJECTION_REBUILD, + ActionOwner.AUTH_REV_12, + ), +} + + def _index_service_actions( rows: dict[ServiceIdentity, frozenset[ActionId]], ) -> MappingProxyType[ServiceIdentity, frozenset[ActionId]]: - expected_metadata = { - ActionId.TASK_POST_SUBMIT_ROUTE: ( - PermissionId.TASK_POST_SUBMIT_ROUTE, - ActionOwner.ARCH_04E2, - ), - ActionId.CHECKER_PRE_SUBMIT_EXECUTE: ( - PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, - ActionOwner.PILOT_04, - ), - ActionId.CHECKER_PRE_SUBMIT_FINALIZE: ( - PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, - ActionOwner.PILOT_04, - ), - ActionId.CHECKER_POST_SUBMIT_EXECUTE: ( - PermissionId.CHECKER_POST_SUBMIT_EXECUTE, - ActionOwner.ARCH_04D2, - ), - ActionId.CHECKER_POST_SUBMIT_FINALIZE: ( - PermissionId.CHECKER_POST_SUBMIT_FINALIZE, - ActionOwner.ARCH_04D2, - ), - ActionId.CHECKER_MODEL_CALL_PREPARE: ( - PermissionId.CHECKER_MODEL_CALL_PREPARE, - ActionOwner.PILOT_05, - ), - ActionId.CHECKER_MODEL_CALL_DISPATCH: ( - PermissionId.CHECKER_MODEL_CALL_DISPATCH, - ActionOwner.PILOT_05, - ), - ActionId.CHECKER_MODEL_CALL_RECOVER: ( - PermissionId.CHECKER_MODEL_CALL_RECOVER, - ActionOwner.PILOT_05, - ), - ActionId.CHECKER_MODEL_CALL_OBSERVE: ( - PermissionId.CHECKER_MODEL_CALL_OBSERVE, - ActionOwner.PILOT_05, - ), - ActionId.CHECKER_MODEL_CALL_COMPLETE: ( - PermissionId.CHECKER_MODEL_CALL_COMPLETE, - ActionOwner.PILOT_05, - ), - ActionId.CHECKER_MODEL_CALL_GRANT: ( - PermissionId.CHECKER_MODEL_CALL_GRANT, - ActionOwner.PILOT_05, - ), - ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE: ( - PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, - ActionOwner.PILOT_05, - ), - ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH: ( - PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, - ActionOwner.PILOT_05, - ), - ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE: ( - PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, - ActionOwner.PILOT_05, - ), - ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE: ( - PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, - ActionOwner.PILOT_05, - ), - ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE: ( - PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, - ActionOwner.ARCH_03C1, - ), - ActionId.TASK_REVISION_EXPIRE: (PermissionId.TASK_REVISION_EXPIRE, ActionOwner.PILOT_08), - ActionId.TASK_REVISION_RECOVER: (PermissionId.TASK_REVISION_RECOVER, ActionOwner.PILOT_08), - ActionId.OUTBOX_DISPATCH: (PermissionId.OUTBOX_DISPATCH, ActionOwner.AUTH_OUTBOX_01), - ActionId.ARTIFACT_VERIFICATION_EXECUTE: ( - PermissionId.ARTIFACT_VERIFICATION_EXECUTE, - ActionOwner.AUTH_ART_02D_INTERNAL, - ), - ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ( - PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, - ActionOwner.AUTH_ART_02D_INTERNAL, - ), - ActionId.ARTIFACT_PENDING_WORK_SCAN: ( - PermissionId.ARTIFACT_PENDING_WORK_SCAN, - ActionOwner.AUTH_ART_02D_INTERNAL, - ), - ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE: ( - PermissionId.ARTIFACT_BINDING_CREATE, - ActionOwner.AUTH_ART_05, - ), - ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE: ( - PermissionId.ARTIFACT_BINDING_CREATE, - ActionOwner.AUTH_ART_06B, - ), - ActionId.ARTIFACT_GUIDE_SOURCE_READ: ( - PermissionId.ARTIFACT_GUIDE_SOURCE_READ, - ActionOwner.XINT_002_04B, - ), - ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( - PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, - ActionOwner.XINT_002_06A, - ), - ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( - PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, - ActionOwner.AUTH_ART_06A, - ), - ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE: ( - PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, - ActionOwner.AUTH_ART_06B, - ), - ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE: ( - PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, - ActionOwner.XINT_002_07, - ), - ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE: ( - PermissionId.ARTIFACT_BINDING_CREATE, - ActionOwner.XINT_002_07, - ), - ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: ( - PermissionId.PROJECT_GUIDE_MANAGE, - ActionOwner.AUTH_12E, - ), - ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC: ( - PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, - ActionOwner.AUTH_12I, - ), - ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE: ( - PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, - ActionOwner.AUTH_12I, - ), - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: ( - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F3, - ), - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: ( - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12G, - ), - ActionId.PROJECT_SETUP_RUN_UPDATE: ( - PermissionId.PROJECT_GUIDE_MANAGE, - ActionOwner.AUTH_12B2, - ), - ActionId.REVIEW_PREFERENCE_EXPIRY_RUN: ( - PermissionId.OPERATIONS_TIMER_RUN, - ActionOwner.AUTH_REV_06, - ), - ActionId.REVIEW_LEASE_EXPIRY_RUN: ( - PermissionId.OPERATIONS_TIMER_RUN, - ActionOwner.AUTH_REV_06, - ), - ActionId.REVIEW_RECONCILE_RUN: ( - PermissionId.OPERATIONS_RECONCILE_RUN, - ActionOwner.AUTH_REV_11, - ), - ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE: ( - PermissionId.OPERATIONS_RECONCILE_RUN, - ActionOwner.AUTH_REV_12, - ), - ActionId.REVIEW_PROJECTION_REBUILD: ( - PermissionId.OPERATIONS_PROJECTION_REBUILD, - ActionOwner.AUTH_REV_12, - ), - } if set(rows) != ACTION_BEARING_SERVICE_IDENTITIES: raise RuntimeError("service action matrix identity mismatch") if any(not actions for actions in rows.values()): @@ -1835,7 +1126,7 @@ def _index_service_actions( action for actions in rows.values() for action in actions ): raise RuntimeError("future-intent action cannot enter the service matrix") - for action, (permission, owner) in expected_metadata.items(): + for action, (permission, owner) in _SERVICE_ACTION_METADATA.items(): definition = ACTION_BY_ID[action] expected_availability = ( ActionAvailability.ACTIVE diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 3e1fb6335..98edb7adc 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -1,20 +1,20 @@ """Deny-by-default request-scoped authorization kernel.""" - from __future__ import annotations from app.modules.authorization.domain.guide_proposals import GUIDE_PROPOSAL_RESOURCE_BY_ACTION from app.modules.authorization.catalogue import POST_POLICY_ACTION_IDS from app.modules.authorization.domain.post_policy import PostPolicyResourceContext - from collections.abc import Awaitable, Callable from types import MappingProxyType from typing import NoReturn from uuid import UUID from app.core.identifiers import new_record_id - from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession - -from app.modules.audit.schemas import ActorReferenceKind, AuthorityAuditEventInput, AuthorityEventType +from app.modules.audit.schemas import ( + ActorReferenceKind, + AuthorityAuditEventInput, + AuthorityEventType, +) from app.modules.audit.service import AuditService from app.modules.authorization.catalogue import ( ACTION_BY_ID, @@ -24,7 +24,16 @@ PermissionId, ) from app.modules.authorization.domain.guide_manager_resources import guide_manager_resource_denial -from app.modules.authorization.domain import adapter_bindings, artifact_operator, model_usage as checker_models, checker_registry, checker_retry, contribution_policies, guide_compilation as compilation, post_submit_deliveries +from app.modules.authorization.domain import ( + adapter_bindings, + artifact_operator, + model_usage as checker_models, + checker_registry, + checker_retry, + contribution_policies, + guide_compilation as compilation, + post_submit_deliveries, +) from app.modules.authorization.domain.action_groups import ( GUIDE_BOUND_PROJECT_MANAGER_ACTIONS as _GUIDE_BOUND_PROJECT_MANAGER_ACTIONS, CONTEXT_DIGEST_ACTIONS, @@ -35,12 +44,14 @@ from app.modules.authorization.domain.audit_targets import project_authority_audit_target from app.modules.authorization.domain.lifecycle import ReviewLifecycleActivationContract from app.modules.authorization.domain.prepared_service import ( - fixed_service_scope_project, fixed_service_resource_matches, + fixed_service_scope_project, + fixed_service_resource_matches, ) from app.modules.authorization.policy import ACTIVE_GUIDE_ADMIN_ROLES from app.modules.authorization.domain.read_dispatch import READ_ACTIONS, read_denial from app.modules.authorization.prepared_admin_authority import ( - lock_admin_mutation_authority, lock_project_create_authority, + lock_admin_mutation_authority, + lock_project_create_authority, lock_project_manager_authority, project_create_prelocked_denial, ) @@ -103,83 +114,99 @@ evaluate_project_authority, lock_project_authority, ) - ContextRevalidator = Callable[ [ HumanAuthorizationContext, - ActorSelfResourceContext - | ActorAuthorizationContextResourceContext - | ProjectReadResourceContext, + ActorSelfResourceContext | ActorAuthorizationContextResourceContext | ProjectReadResourceContext, ], Awaitable[HumanAuthorizationContext], ] - - ServiceContextRevalidator = Callable[ [ServiceAuthorizationContext, ActionId], Awaitable[ServiceAuthorizationContext | None], ] - -_ADMIN_ACTIONS = frozenset( - { - ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, - ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, - ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ, - ActionId.ADMIN_ROLE_GRANT_LIST, - ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ, - ActionId.ADMIN_ROLE_GRANT_ISSUE, - ActionId.ADMIN_ROLE_GRANT_REVOKE, - ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, - ActionId.ACTOR_SERVICE_PROVISION, - ActionId.ACTOR_PROFILE_READ, - ActionId.ACTOR_IDENTITY_LINK_READ, - ActionId.ACTOR_PROFILE_SUSPEND, - ActionId.ACTOR_PROFILE_REACTIVATE, - ActionId.ACTOR_PROFILE_DEACTIVATE, - ActionId.ACTOR_IDENTITY_LINK_REVOKE, - ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, - ActionId.PROJECT_ROLE_GRANT_ISSUE, - ActionId.PROJECT_ROLE_GRANT_REVOKE, - ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, - ActionId.PROJECT_ROLE_GRANT_LIST, - ActionId.PROJECT_ROLE_GRANT_READ, - ActionId.PROJECT_SETUP_RUN_READ, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, - ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, - ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, - ActionId.PROJECT_ACTIVE_GUIDE_READ, - } -) | adapter_bindings.ADAPTER_BINDING_READ_ACTIONS | contribution_policies.CONTRIBUTION_POLICY_READ_ACTIONS | checker_registry.CHECKER_REGISTRY_ACTIONS | checker_models.MODEL_ADMIN_ACTIONS | checker_retry.CHECKER_RETRY_ACTIONS | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS | artifact_operator.ARTIFACT_OPERATOR_ACTIONS -_SERIALIZED_ADMIN_READS = frozenset( - { - ActionId.ACTOR_PROFILE_READ, - ActionId.ACTOR_IDENTITY_LINK_READ, - ActionId.PROJECT_SETUP_RUN_READ, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, - ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, - ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, - ActionId.PROJECT_ACTIVE_GUIDE_READ, - } -) | adapter_bindings.ADAPTER_BINDING_READ_ACTIONS | contribution_policies.CONTRIBUTION_POLICY_READ_ACTIONS | checker_registry.CHECKER_REGISTRY_READ_ACTIONS | artifact_operator.ARTIFACT_OPERATOR_READ_ACTIONS -_ADMIN_MUTATIONS = frozenset( - { - ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, - ActionId.ADMIN_ROLE_GRANT_REVOKE, - ActionId.ACTOR_SERVICE_PROVISION, - ActionId.ACTOR_PROFILE_SUSPEND, - ActionId.ACTOR_PROFILE_REACTIVATE, - ActionId.ACTOR_PROFILE_DEACTIVATE, - ActionId.ACTOR_IDENTITY_LINK_REVOKE, - ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, - } -) | PROJECT_SCOPED_ADMIN_MUTATIONS | checker_registry.CHECKER_REGISTRY_MUTATION_ACTIONS | checker_models.MODEL_ADMIN_ACTIONS | checker_retry.CHECKER_RETRY_ACTIONS | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS | {ActionId.ARTIFACT_VERIFICATION_JOB_RETRY} - +_ADMIN_ACTIONS = ( + frozenset( + { + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, + ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, + ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ, + ActionId.ADMIN_ROLE_GRANT_LIST, + ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ, + ActionId.ADMIN_ROLE_GRANT_ISSUE, + ActionId.ADMIN_ROLE_GRANT_REVOKE, + ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, + ActionId.ACTOR_SERVICE_PROVISION, + ActionId.ACTOR_PROFILE_READ, + ActionId.ACTOR_IDENTITY_LINK_READ, + ActionId.ACTOR_PROFILE_SUSPEND, + ActionId.ACTOR_PROFILE_REACTIVATE, + ActionId.ACTOR_PROFILE_DEACTIVATE, + ActionId.ACTOR_IDENTITY_LINK_REVOKE, + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + ActionId.PROJECT_ROLE_GRANT_ISSUE, + ActionId.PROJECT_ROLE_GRANT_REVOKE, + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_ROLE_GRANT_LIST, + ActionId.PROJECT_ROLE_GRANT_READ, + ActionId.PROJECT_SETUP_RUN_READ, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, + ActionId.PROJECT_ACTIVE_GUIDE_READ, + } + ) + | adapter_bindings.ADAPTER_BINDING_READ_ACTIONS + | contribution_policies.CONTRIBUTION_POLICY_READ_ACTIONS + | checker_registry.CHECKER_REGISTRY_ACTIONS + | checker_models.MODEL_ADMIN_ACTIONS + | checker_retry.CHECKER_RETRY_ACTIONS + | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS + | artifact_operator.ARTIFACT_OPERATOR_ACTIONS +) +_SERIALIZED_ADMIN_READS = ( + frozenset( + { + ActionId.ACTOR_PROFILE_READ, + ActionId.ACTOR_IDENTITY_LINK_READ, + ActionId.PROJECT_SETUP_RUN_READ, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, + ActionId.PROJECT_ACTIVE_GUIDE_READ, + } + ) + | adapter_bindings.ADAPTER_BINDING_READ_ACTIONS + | contribution_policies.CONTRIBUTION_POLICY_READ_ACTIONS + | checker_registry.CHECKER_REGISTRY_READ_ACTIONS + | artifact_operator.ARTIFACT_OPERATOR_READ_ACTIONS +) +_ADMIN_MUTATIONS = ( + frozenset( + { + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, + ActionId.ADMIN_ROLE_GRANT_REVOKE, + ActionId.ACTOR_SERVICE_PROVISION, + ActionId.ACTOR_PROFILE_SUSPEND, + ActionId.ACTOR_PROFILE_REACTIVATE, + ActionId.ACTOR_PROFILE_DEACTIVATE, + ActionId.ACTOR_IDENTITY_LINK_REVOKE, + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + } + ) + | PROJECT_SCOPED_ADMIN_MUTATIONS + | checker_registry.CHECKER_REGISTRY_MUTATION_ACTIONS + | checker_models.MODEL_ADMIN_ACTIONS + | checker_retry.CHECKER_RETRY_ACTIONS + | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS + | {ActionId.ARTIFACT_VERIFICATION_JOB_RETRY} +) def _prepared_admin_role_locker(action_id): """Select the only supported prepared admin-role authority locker.""" if action_id in _ADMIN_MUTATIONS: @@ -188,9 +215,7 @@ def _prepared_admin_role_locker(action_id): return lock_project_manager_authority raise ValueError("unsupported prepared admin-role action") -async def prelock_serialized_admin_read_actor( - authorization: AuthorizationService, action_id: ActionId -) -> None: +async def prelock_serialized_admin_read_actor(authorization: AuthorizationService, action_id: ActionId) -> None: """Order caller custody before product rows; final require still decides access.""" if action_id not in _SERIALIZED_ADMIN_READS: raise ValueError("unsupported serialized administrative read") @@ -202,8 +227,14 @@ async def prelock_serialized_admin_read_actor( _ARTIFACT_INTERNAL_RESOURCES = { ActionId.ARTIFACT_GUIDE_SOURCE_READ: ("guide_source_read", GuideSourceReadResourceContext), - ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: ("pre_submit_checker_input", PreSubmitCheckerInputResourceContext), - ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE: ("submission_binding", SubmissionBindingResourceContext), + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( + "pre_submit_checker_input", + PreSubmitCheckerInputResourceContext, + ), + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE: ( + "submission_binding", + SubmissionBindingResourceContext, + ), ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ( "artifact_put_attempt", ArtifactPutAttemptResourceContext, @@ -235,9 +266,7 @@ async def prelock_serialized_admin_read_actor( ActionId.ACTOR_PROFILE_DEACTIVATE: ActorProfileLifecycleResourceContext, ActionId.ACTOR_IDENTITY_LINK_REVOKE: ActorIdentityLinkLifecycleResourceContext, ActionId.ACTOR_IDENTITY_LINK_REACTIVATE: ActorIdentityLinkLifecycleResourceContext, - ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST: ( - ProjectContributorCandidateCollectionResourceContext - ), + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST: (ProjectContributorCandidateCollectionResourceContext), ActionId.PROJECT_ROLE_GRANT_LIST: ProjectRoleGrantCollectionResourceContext, ActionId.PROJECT_ROLE_GRANT_READ: ProjectRoleGrantReadResourceContext, ActionId.PROJECT_ROLE_GRANT_ISSUE: ProjectRoleGrantIssueResourceContext, @@ -251,14 +280,17 @@ async def prelock_serialized_admin_read_actor( ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ: ProjectPolicyReadResourceContext, ActionId.PROJECT_ACTIVE_GUIDE_READ: ProjectActiveGuideReadResourceContext, **adapter_bindings.ADAPTER_BINDING_RESOURCE_BY_ACTION, - **contribution_policies.CONTRIBUTION_POLICY_RESOURCE_BY_ACTION, **checker_registry.CHECKER_REGISTRY_RESOURCE_BY_ACTION, + **contribution_policies.CONTRIBUTION_POLICY_RESOURCE_BY_ACTION, + **checker_registry.CHECKER_REGISTRY_RESOURCE_BY_ACTION, **checker_models.MODEL_ADMIN_RESOURCE_BY_ACTION, - ActionId.OPERATIONS_CHECKER_RETRY: checker_retry.CheckerRetryResourceContext, **post_submit_deliveries.POST_SUBMIT_DELIVERY_RESOURCE_BY_ACTION, + ActionId.OPERATIONS_CHECKER_RETRY: checker_retry.CheckerRetryResourceContext, + **post_submit_deliveries.POST_SUBMIT_DELIVERY_RESOURCE_BY_ACTION, **artifact_operator.ARTIFACT_OPERATOR_RESOURCE_BY_ACTION, **PROJECT_MUTATION_RESOURCE_BY_ACTION, **GUIDE_PROPOSAL_RESOURCE_BY_ACTION, } ) + def project_action_available_for_status(action_id: ActionId, project_status: str) -> bool: """Apply project-only lifecycle guards shared by decisions and projections.""" if action_id in { @@ -365,12 +397,7 @@ def _register_prepared_consumer( """Issue one opaque kernel token only to an exactly composed PREP service.""" from app.modules.authorization.prepared import PreparedAuthorizationService - if ( - type(owner) is not PreparedAuthorizationService - or session is not self._session - or repository is not self._admin - or context != self._context - ): + if type(owner) is not PreparedAuthorizationService or session is not self._session or repository is not self._admin or context != self._context: raise TypeError("invalid prepared authorization composition") token = object() self._prepared_consumers[token] = owner @@ -408,9 +435,7 @@ async def _prepare_prelocked( raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.ACTION_UNAVAILABLE) expected_resource = _ARTIFACT_INTERNAL_RESOURCES.get(action_id) project_id = fixed_service_scope_project(action_id, scope, expected_resource) - locked = await self._admin.lock_request_actor( - context.identity_link_id, context.actor_profile_id - ) + locked = await self._admin.lock_request_actor(context.identity_link_id, context.actor_profile_id) context = self._locked_service_context(locked, context) lifecycle = self._lifecycle_denial(context) if lifecycle is not None: @@ -434,40 +459,39 @@ async def _prepare_prelocked( if action.availability is not ActionAvailability.ACTIVE: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.ACTION_UNAVAILABLE) if action_id is ActionId.ACTOR_PROFILE_UPDATE_SELF: - if ( - scope.kind is not PreparedAuthorityScopeKind.ACTOR_SELF - or scope.actor_profile_id != context.actor_profile_id - ): - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.RESOURCE_GUARD_DENIED - ) - locked = await self._admin.lock_actor_self( - context.actor_profile_id, context.identity_link_id - ) + if scope.kind is not PreparedAuthorityScopeKind.ACTOR_SELF or scope.actor_profile_id != context.actor_profile_id: + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.RESOURCE_GUARD_DENIED) + locked = await self._admin.lock_actor_self(context.actor_profile_id, context.identity_link_id) context = self._locked_human_context(locked, context) elif action_id in _ADMIN_MUTATIONS | _GUIDE_BOUND_PROJECT_MANAGER_ACTIONS: context, grant = await _prepared_admin_role_locker(action_id)( - self._admin, context, scope, action_id, action.permission_id, self._locked_human_context, + self._admin, + context, + scope, + action_id, + action.permission_id, + self._locked_human_context, ) elif action_id is ActionId.PROJECT_CREATE: context, grant = await lock_project_create_authority( - self._admin, context, scope, self._locked_human_context, + self._admin, + context, + scope, + self._locked_human_context, ) elif action_id in PROJECT_AUTHORITY_ACTIONS: context, grant = await lock_project_authority( - self._admin, context, scope, action, self._locked_human_context, + self._admin, + context, + scope, + action, + self._locked_human_context, ) else: - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.PERMISSION_NOT_GRANTED - if action_id in _ARTIFACT_INTERNAL_RESOURCES - else AuthorizationDenialCode.ACTION_UNAVAILABLE - ) + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.PERMISSION_NOT_GRANTED if action_id in _ARTIFACT_INTERNAL_RESOURCES else AuthorizationDenialCode.ACTION_UNAVAILABLE) lifecycle = self._lifecycle_denial(context) if lifecycle is not None or context.actor_kind is not ActorKind.HUMAN: - raise PreparedAuthorizationUnsupported( - lifecycle or AuthorizationDenialCode.PERMISSION_NOT_GRANTED - ) + raise PreparedAuthorizationUnsupported(lifecycle or AuthorizationDenialCode.PERMISSION_NOT_GRANTED) authority = _PrelockedAuthority( _PRELOCKED_CONSTRUCTOR_TOKEN, issuer=self, @@ -476,11 +500,7 @@ async def _prepare_prelocked( action_id=action_id, scope_project_id=scope.project_id, matched_grant_id=UUID(str(grant.id)) if grant is not None else None, - matched_grant_scope_project_id=( - UUID(str(grant.scope_project_id)) - if grant is not None and getattr(grant, "scope_project_id", None) is not None - else None - ), + matched_grant_scope_project_id=(UUID(str(grant.scope_project_id)) if grant is not None and getattr(grant, "scope_project_id", None) is not None else None), matched_grant_status=grant.status if grant is not None else None, permission_id=action.permission_id, ) @@ -492,11 +512,7 @@ def _locked_human_context(locked, original) -> HumanAuthorizationContext: if locked is None: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.IDENTITY_LINK_REVOKED) link, profile = locked - if ( - profile.id != str(original.actor_profile_id) - or link.id != str(original.identity_link_id) - or link.actor_profile_id != profile.id - ): + if profile.id != str(original.actor_profile_id) or link.id != str(original.identity_link_id) or link.actor_profile_id != profile.id: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.PERMISSION_NOT_GRANTED) return HumanAuthorizationContext( actor_profile_id=UUID(profile.id), @@ -513,13 +529,7 @@ def _locked_service_context(locked, original) -> ServiceAuthorizationContext: if locked is None: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.IDENTITY_LINK_REVOKED) link, profile = locked - if ( - profile.id != str(original.actor_profile_id) - or link.id != str(original.identity_link_id) - or link.actor_profile_id != profile.id - or profile.actor_kind != ActorKind.SERVICE.value - or profile.service_identity != original.service_identity.value - ): + if profile.id != str(original.actor_profile_id) or link.id != str(original.identity_link_id) or link.actor_profile_id != profile.id or profile.actor_kind != ActorKind.SERVICE.value or profile.service_identity != original.service_identity.value: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.PERMISSION_NOT_GRANTED) return ServiceAuthorizationContext( actor_profile_id=UUID(profile.id), @@ -590,16 +600,24 @@ async def require( if denial is None: matched_kind = MatchedAuthorityKind.ADMIN_ROLE_GRANT elif action is not None and action.action_id in READ_ACTIONS: - denial, context, matched_kind, matched_grant_id, matched_project_id, revalidated = await read_denial( - action, resource_context, context, self._admin, - self._locked_human_context, self._lifecycle_denial, self._revalidate_actor_self, + ( + denial, + context, + matched_kind, + matched_grant_id, + matched_project_id, + revalidated, + ) = await read_denial( + action, + resource_context, + context, + self._admin, + self._locked_human_context, + self._lifecycle_denial, + self._revalidate_actor_self, ) elif action is not None and action.action_id is ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ: - if ( - isinstance(context, HumanAuthorizationContext) - and isinstance(resource_context, ActorAuthorizationContextResourceContext) - and self._revalidate_actor_self is not None - ): + if isinstance(context, HumanAuthorizationContext) and isinstance(resource_context, ActorAuthorizationContextResourceContext) and self._revalidate_actor_self is not None: context = await self._revalidate_actor_self(context, resource_context) revalidated = True ( @@ -607,9 +625,7 @@ async def require( matched_kind, matched_grant_id, matched_project_id, - ) = await self._authorization_context_denial( - action, resource_context, context, revalidated - ) + ) = await self._authorization_context_denial(action, resource_context, context, revalidated) else: if ( action is not None @@ -650,12 +666,7 @@ async def _authorization_context_denial( UUID | None, ]: """Authorize a caller-owned context projection for one exact project.""" - if ( - not isinstance(context, HumanAuthorizationContext) - or not isinstance(resource, ActorAuthorizationContextResourceContext) - or resource.resource_id != context.actor_profile_id - or not revalidated - ): + if not isinstance(context, HumanAuthorizationContext) or not isinstance(resource, ActorAuthorizationContextResourceContext) or resource.resource_id != context.actor_profile_id or not revalidated: return AuthorizationDenialCode.RESOURCE_GUARD_DENIED, None, None, None if action.availability is not ActionAvailability.ACTIVE: return AuthorizationDenialCode.ACTION_UNAVAILABLE, None, None, None @@ -686,13 +697,9 @@ async def _authorization_context_denial( for_update=True, ) if project_grant is None: - out_of_scope = await self._admin.has_effective_permission_any_scope( - context.actor_profile_id, PermissionId.PROJECT_READ - ) or await self._admin.has_active_project_role_any_project(context.actor_profile_id) + out_of_scope = await self._admin.has_effective_permission_any_scope(context.actor_profile_id, PermissionId.PROJECT_READ) or await self._admin.has_active_project_role_any_project(context.actor_profile_id) return ( - AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED - if out_of_scope - else AuthorizationDenialCode.PERMISSION_NOT_GRANTED, + AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED if out_of_scope else AuthorizationDenialCode.PERMISSION_NOT_GRANTED, None, None, None, @@ -714,15 +721,7 @@ async def _require_prelocked( """Evaluate final facts using exact authority already locked by AUTH.""" self._validate_prepared_consumer(consumer_token) transaction = self._session.sync_session.get_transaction() - if ( - type(authority) is not _PrelockedAuthority - or authority not in self._sealed_prelocked - or authority.issuer is not self - or transaction is None - or not transaction.is_active - or transaction is not authority.transaction - or self._session.in_nested_transaction() - ): + if type(authority) is not _PrelockedAuthority or authority not in self._sealed_prelocked or authority.issuer is not self or transaction is None or not transaction.is_active or transaction is not authority.transaction or self._session.in_nested_transaction(): raise TypeError("invalid prelocked authority") self._sealed_prelocked.remove(authority) self._pending_denial = None @@ -730,8 +729,7 @@ async def _require_prelocked( action = ACTION_BY_ID.get(action_id) if isinstance(action_id, ActionId) else None context = authority.context denial: AuthorizationDenialCode | None - matched_kind = None - matched_grant_id = None + matched_kind, matched_grant_id = None, None matched_project_id = None if action is None or authority.action_id is not action_id: denial = AuthorizationDenialCode.UNKNOWN_ACTION @@ -745,7 +743,12 @@ async def _require_prelocked( if denial is None and action_id not in SERVICE_ACTIONS_BY_IDENTITY.get(context.service_identity, ()): denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED if denial is None and not fixed_service_resource_matches( - action_id, resource_context, authority.scope_project_id, authority.artifact_resource_type, authority.artifact_resource_id, expected_resource, + action_id, + resource_context, + authority.scope_project_id, + authority.artifact_resource_type, + authority.artifact_resource_id, + expected_resource, ): denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED if denial is None: @@ -763,9 +766,7 @@ async def _require_prelocked( final_scope = self._resource_project_id(resource_context) if denial is None and final_scope != authority.scope_project_id: denial = AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED - if denial is None and ( - authority.matched_grant_id is None or authority.matched_grant_status != "active" - ): + if denial is None and (authority.matched_grant_id is None or authority.matched_grant_status != "active"): denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED if denial is None: denial = await self._admin_guard(action_id, resource_context, context) @@ -779,9 +780,7 @@ async def _require_prelocked( denial = AuthorizationDenialCode.ACTION_UNAVAILABLE if denial is None: denial = guide_manager_resource_denial(action_id, resource_context, authority.scope_project_id) - if denial is None and ( - authority.matched_grant_id is None or authority.matched_grant_status != "active" - ): + if denial is None and (authority.matched_grant_id is None or authority.matched_grant_status != "active"): denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED if denial is None: matched_kind = MatchedAuthorityKind.ADMIN_ROLE_GRANT @@ -789,14 +788,21 @@ async def _require_prelocked( matched_project_id = authority.matched_grant_scope_project_id elif action_id is ActionId.PROJECT_CREATE: denial = project_create_prelocked_denial( - action, resource_context, authority, self._lifecycle_denial(context), + action, + resource_context, + authority, + self._lifecycle_denial(context), ) if denial is None: matched_kind = MatchedAuthorityKind.ADMIN_ROLE_GRANT matched_grant_id = authority.matched_grant_id elif action_id in PROJECT_AUTHORITY_ACTIONS: denial, matched_kind, matched_grant_id, matched_project_id = evaluate_project_authority( - action, context, authority, resource_context, self._lifecycle_denial(context), + action, + context, + authority, + resource_context, + self._lifecycle_denial(context), ) else: denial = AuthorizationDenialCode.ACTION_UNAVAILABLE @@ -882,11 +888,7 @@ async def _service_denial( lifecycle = self._lifecycle_denial(refreshed) if lifecycle is not None: return lifecycle, refreshed, True - if ( - refreshed.service_identity is not context.service_identity - or action.action_id not in SERVICE_ACTIONS_BY_IDENTITY.get(refreshed.service_identity, ()) - or ACTION_BY_ID[action.action_id].availability is not ActionAvailability.ACTIVE - ): + if refreshed.service_identity is not context.service_identity or action.action_id not in SERVICE_ACTIONS_BY_IDENTITY.get(refreshed.service_identity, ()) or ACTION_BY_ID[action.action_id].availability is not ActionAvailability.ACTIVE: return AuthorizationDenialCode.PERMISSION_NOT_GRANTED, refreshed, True return AuthorizationDenialCode.RESOURCE_GUARD_DENIED, refreshed, True @@ -946,10 +948,13 @@ async def _admin_denial( if action.action_id is ActionId.PROJECT_ACTIVE_GUIDE_READ: grant_filters["allowed_roles"] = ACTIVE_GUIDE_ADMIN_ROLES else: - grant_filters.update( - adapter_bindings.finance_authority_grant_filters(action.action_id) - ) - grant_filters.update({**contribution_policies.policy_finance_grant_filters(action.action_id), **checker_registry.checker_registry_grant_filters(action.action_id)}) + grant_filters.update(adapter_bindings.finance_authority_grant_filters(action.action_id)) + grant_filters.update( + { + **contribution_policies.policy_finance_grant_filters(action.action_id), + **checker_registry.checker_registry_grant_filters(action.action_id), + } + ) if action.action_id in checker_retry.CHECKER_RETRY_ACTIONS | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS | checker_models.MODEL_ADMIN_ACTIONS | artifact_operator.ARTIFACT_OPERATOR_ACTIONS: grant_filters["allowed_roles"] = (AdminRole.OPERATOR,) matched = await self._admin.find_effective_grant( @@ -991,7 +996,14 @@ async def _admin_guard( context: AuthorizationContext, ) -> AuthorizationDenialCode | None: """Apply target existence and self-authority guards after permission match.""" - if type(resource) in (artifact_operator.ArtifactOperatorReadResourceContext, artifact_operator.ArtifactOperatorRetryResourceContext) and resource.identity_link_id != context.identity_link_id: + if ( + type(resource) + in ( + artifact_operator.ArtifactOperatorReadResourceContext, + artifact_operator.ArtifactOperatorRetryResourceContext, + ) + and resource.identity_link_id != context.identity_link_id + ): return AuthorizationDenialCode.RESOURCE_GUARD_DENIED if isinstance(resource, AdminRoleGrantIssueResourceContext): if resource.resource_id == context.actor_profile_id: @@ -1005,9 +1017,7 @@ async def _admin_guard( return AuthorizationDenialCode.RESOURCE_NOT_FOUND elif isinstance(resource, AdminRoleGrantResourceContext): grant = await self._admin.get_grant(resource.resource_id, for_update=True) - if grant is None or ( - grant.status != "active" and not resource.existing_idempotency_record - ): + if grant is None or (grant.status != "active" and not resource.existing_idempotency_record): return AuthorizationDenialCode.GRANT_NOT_FOUND if grant.target_actor_profile_id == str(context.actor_profile_id): return AuthorizationDenialCode.SELF_ROLE_REVOKE_FORBIDDEN @@ -1045,13 +1055,9 @@ async def _admin_guard( resource, (AdminRoleGrantCollectionResourceContext, ActorAdminRoleGrantHistoryResourceContext), ): - if resource.scope_project_id is not None and not await self._admin.project_exists( - resource.scope_project_id - ): + if resource.scope_project_id is not None and not await self._admin.project_exists(resource.scope_project_id): return AuthorizationDenialCode.RESOURCE_NOT_FOUND - if isinstance( - resource, ActorAdminRoleGrantHistoryResourceContext - ) and not await self._admin.actor_exists(resource.resource_id): + if isinstance(resource, ActorAdminRoleGrantHistoryResourceContext) and not await self._admin.actor_exists(resource.resource_id): return AuthorizationDenialCode.ACTOR_NOT_FOUND elif isinstance(resource, ProjectContributorCandidateCollectionResourceContext): if not project_action_available_for_status(action_id, resource.project_status): @@ -1118,12 +1124,7 @@ def _lifecycle_denial( async def restage_denial(self, decision: AuthorizationDecision) -> None: """Restage the exact pending denial after composition-root rollback.""" - if ( - decision.allowed - or decision is not self._pending_denial - or decision.request_id != self._context.request_id - or decision.correlation_id != self._context.correlation_id - ): + if decision.allowed or decision is not self._pending_denial or decision.request_id != self._context.request_id or decision.correlation_id != self._context.correlation_id: raise TypeError("invalid authorization denial evidence") resource_context = self._pending_denial_resource_context if resource_context is None: @@ -1208,18 +1209,23 @@ async def _stage_decision( audit_resource_id = None target_ref_kind = None target_ref_id = None - audit_project_id = ( - str(decision.matched_scope_project_id) - if decision.matched_scope_project_id is not None - else None - ) + audit_project_id = str(decision.matched_scope_project_id) if decision.matched_scope_project_id is not None else None if target_is_actor: audit_resource_type = "actor_profile" elif decision.resource_type in {"actor_identity_link", "admin_role_grant"}: audit_resource_type = decision.resource_type - if exact_project_target := project_authority_audit_target(resource_context, decision.action_id, matched_scope_project_id=decision.matched_scope_project_id): - (target_project_id, audit_resource_type, audit_resource_id, - target_ref_kind, target_ref_id) = exact_project_target + if exact_project_target := project_authority_audit_target( + resource_context, + decision.action_id, + matched_scope_project_id=decision.matched_scope_project_id, + ): + ( + target_project_id, + audit_resource_type, + audit_resource_id, + target_ref_kind, + target_ref_id, + ) = exact_project_target if target_project_id is not None: audit_project_id = target_project_id default_resource_id = str(decision.resource_id) if audit_resource_type else None @@ -1233,26 +1239,16 @@ async def _stage_decision( await self._audit.add_authority_event( AuthorityAuditEventInput( event_id=decision.decision_id, - event_type=( - AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED - if decision.allowed - else AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED - ), + event_type=(AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED if decision.allowed else AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED), entity_type="authorization_decision", entity_id=str(decision.decision_id), actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, actor_ref=str(actor_profile_id), request_id=decision.request_id, correlation_id=decision.correlation_id, - target_actor_ref_kind=( - ActorReferenceKind.ACTOR_PROFILE if target_is_actor else None - ), + target_actor_ref_kind=(ActorReferenceKind.ACTOR_PROFILE if target_is_actor else None), target_actor_ref=str(decision.resource_id) if target_is_actor else None, - matched_grant_id=( - str(decision.matched_grant_id) - if decision.matched_grant_id is not None - else None - ), + matched_grant_id=(str(decision.matched_grant_id) if decision.matched_grant_id is not None else None), permission_id=decision.permission_id, action_id=decision.action_id, project_id=audit_project_id, diff --git a/backend/app/modules/checkers/api/model_grants.py b/backend/app/modules/checkers/api/model_grants.py deleted file mode 100644 index acb5a83fd..000000000 --- a/backend/app/modules/checkers/api/model_grants.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Compatibility aliases for shared private model invocation grants.""" - -from app.modules.model_usage.api import ( - ModelInvocationGrantAuthorityPort, - ModelInvocationGrantFacts, -) - -__all__ = ("ModelInvocationGrantAuthorityPort", "ModelInvocationGrantFacts") diff --git a/backend/app/modules/model_usage/api.py b/backend/app/modules/model_usage/api.py index 347a74c0e..319174191 100644 --- a/backend/app/modules/model_usage/api.py +++ b/backend/app/modules/model_usage/api.py @@ -515,10 +515,21 @@ async def require_setup_response(self, effect_id: UUID, evidence: ModelCallEvide ... +class SetupModelOwnerProfile(ModelCallValue): + """Exact profile and provider identity frozen by the PROJECTS attempt owner.""" + + selector: ModelProfileSelector + provider: ProviderIdentifier + api: ProviderIdentifier + model_snapshot: ProviderIdentifier + + class SetupModelUsageOwnerPort(Protocol): """Require the exact current PROJECTS compilation attempt before MODEL locks.""" - async def require_setup_current(self, slot: SetupModelUsageSlotKey) -> None: + async def require_setup_current( + self, slot: SetupModelUsageSlotKey + ) -> SetupModelOwnerProfile: """Lock and validate the complete setup attempt ancestry.""" ... @@ -1046,6 +1057,7 @@ def model_action_digest(action: str, facts: object) -> str: "SetupModelEffectAuthorityFacts", "SetupModelUsageAuthorityPort", "SetupModelUsageEvidencePort", + "SetupModelOwnerProfile", "SetupModelUsageOwnerPort", "SetupModelUsageSlotKey", "model_usage_cost", diff --git a/backend/app/modules/model_usage/grants.py b/backend/app/modules/model_usage/grants.py index 98a73307a..7144d8be1 100644 --- a/backend/app/modules/model_usage/grants.py +++ b/backend/app/modules/model_usage/grants.py @@ -1,4 +1,4 @@ -"""One-use private callback grants; paid-effect identity remains in the call ledger.""" +"""Bounded private session grants; each paid effect remains in the call ledger.""" import hashlib import json @@ -24,7 +24,10 @@ ModelInvocationGrantFacts, ) from app.modules.model_usage.repository import require_model_call_transaction -from app.modules.model_usage.models import CheckerModelInvocationGrant +from app.modules.model_usage.models import ( + CheckerModelInvocationGrant, + CheckerModelInvocationGrantCall, +) def model_grant_token_hash(token: str) -> str: @@ -38,7 +41,7 @@ def model_grant_token_hash(token: str) -> str: class ModelInvocationGrants: - """Issue and consume invocation custody in caller-owned transactions.""" + """Issue invocation custody and bind each sequential call in caller transactions.""" def __init__( self, @@ -104,7 +107,6 @@ async def resolve(self, token: str, request_digest: str) -> ModelInvocationGrant select(CheckerModelInvocationGrant).where( CheckerModelInvocationGrant.token_hash == token_hash, CheckerModelInvocationGrant.external_request_digest == request_digest, - CheckerModelInvocationGrant.consumed_at.is_(None), CheckerModelInvocationGrant.expires_at > func.clock_timestamp(), ) ) @@ -134,21 +136,25 @@ async def consume_prepared( self, facts: ModelInvocationGrantFacts, *, + call_index: int, request_sha256: str, prepared_attempt_id: UUID, ) -> None: - """Consume after ledger preparation in the same AUTH/TASK/slot transaction.""" + """Bind one sequential call after preparation in the same owner transaction.""" from app.modules.model_usage.repository import ModelCallRepository require_model_call_transaction(self._session) if re.fullmatch(r"sha256:[0-9a-f]{64}", request_sha256) is None: raise ModelCallUnavailable("model_grant_unavailable") + if type(call_index) is not int or not 0 <= call_index <= 99: + raise ModelCallUnavailable("model_grant_unavailable") + selected_slot = facts.slot.model_copy(update={"slot_index": call_index}) repo = ModelCallRepository(self._session) - await repo.lock_envelope_scope(facts.slot) - if await repo.envelope(facts.slot, lock=True) is None: + await repo.lock_envelope_scope(selected_slot) + if await repo.envelope(selected_slot, lock=True) is None: raise ModelCallUnavailable("model_grant_unavailable") - await repo.lock_slot_scope(facts.slot) - slot = await repo.slot(facts.slot, lock=True) + await repo.lock_slot_scope(selected_slot) + slot = await repo.slot(selected_slot, lock=True) attempt = await repo.attempt(prepared_attempt_id, lock=True) if ( slot is None @@ -169,7 +175,6 @@ async def consume_prepared( now = await self._session.scalar(select(func.clock_timestamp())) if ( row is None - or row.consumed_at is not None or row.expires_at <= now or ( row.execution_facts != facts.execution.model_dump(mode="json") @@ -177,38 +182,78 @@ async def consume_prepared( ) ): raise ModelCallUnavailable("model_grant_unavailable") - row.selected_request_sha256, row.consumed_at = request_sha256, now - row.selected_attempt_id = str(prepared_attempt_id) + existing = await self._session.get( + CheckerModelInvocationGrantCall, + {"grant_id": row.id, "call_index": call_index}, + ) + if existing is not None: + if ( + existing.attempt_id != str(prepared_attempt_id) + or existing.request_sha256 != request_sha256 + ): + raise ModelCallUnavailable("model_grant_unavailable") + return + self._session.add( + CheckerModelInvocationGrantCall( + grant_id=row.id, + call_index=call_index, + attempt_id=str(prepared_attempt_id), + request_sha256=request_sha256, + ) + ) await self._session.flush() - async def completed_effect( + async def completed_effects( self, token: str, request_digest: str, execution: ModelCallExecutionFacts, - ) -> ModelCallEffectAuthorityFacts: - """Select exact consumed effect facts; the ledger must freshly authorize their read.""" - from app.modules.model_usage.models import ModelUsageEffect + ) -> tuple[ModelCallEffectAuthorityFacts, ...]: + """Select all session effects; the ledger freshly authorizes each completed read.""" + from app.modules.model_usage.models import CheckerModelUsageSlot, ModelUsageEffect require_model_call_transaction(self._session) row = await self._session.scalar( select(CheckerModelInvocationGrant).where( CheckerModelInvocationGrant.token_hash == model_grant_token_hash(token), CheckerModelInvocationGrant.external_request_digest == request_digest, - CheckerModelInvocationGrant.consumed_at.is_not(None), + CheckerModelInvocationGrant.expires_at > func.clock_timestamp(), ) ) if row is None or row.execution_facts != execution.model_dump(mode="json"): raise ModelCallUnavailable("model_grant_completion_unavailable") - attempt = await self._session.get(ModelUsageEffect, row.selected_attempt_id) - if attempt is None or attempt.request_sha256 != row.selected_request_sha256: + calls = ( + await self._session.execute( + select(CheckerModelInvocationGrantCall, ModelUsageEffect, CheckerModelUsageSlot) + .join( + ModelUsageEffect, + ModelUsageEffect.id == CheckerModelInvocationGrantCall.attempt_id, + ) + .join( + CheckerModelUsageSlot, + CheckerModelUsageSlot.id == ModelUsageEffect.checker_slot_id, + ) + .where(CheckerModelInvocationGrantCall.grant_id == row.id) + .order_by(CheckerModelInvocationGrantCall.call_index) + ) + ).all() + if any( + call.call_index != index + or attempt.request_sha256 != call.request_sha256 + or attempt.state != "completed" + or slot.slot_index != index + for index, (call, attempt, slot) in enumerate(calls) + ): raise ModelCallUnavailable("model_grant_completion_unavailable") - slot = ModelPaidSlotKey.model_validate_json(json.dumps(row.paid_slot)) - return ModelCallEffectAuthorityFacts( - project_id=execution.project_id, - task_id=execution.task_id, - slot_id=UUID(attempt.checker_slot_id), - slot_key=slot, - attempt_id=UUID(attempt.id), - internal_call_request_id=UUID(attempt.internal_call_request_id), + base_slot = ModelPaidSlotKey.model_validate_json(json.dumps(row.paid_slot)) + return tuple( + ModelCallEffectAuthorityFacts( + project_id=execution.project_id, + task_id=execution.task_id, + slot_id=UUID(attempt.checker_slot_id), + slot_key=base_slot.model_copy(update={"slot_index": call.call_index}), + attempt_id=UUID(attempt.id), + internal_call_request_id=UUID(attempt.internal_call_request_id), + ) + for call, attempt, _slot in calls ) diff --git a/backend/app/modules/model_usage/models.py b/backend/app/modules/model_usage/models.py index a7a4db317..c0e63bdaa 100644 --- a/backend/app/modules/model_usage/models.py +++ b/backend/app/modules/model_usage/models.py @@ -390,6 +390,10 @@ class SetupModelUsageEnvelope(Base): "profile_digest", name="setup_model_usage_envelopes_stable_identity", ), + UniqueConstraint( + "compilation_attempt_id", + name="setup_model_usage_envelopes_one_per_attempt", + ), ForeignKeyConstraint( [ "compilation_attempt_id", @@ -712,7 +716,7 @@ class CheckerModelUsageRecovery(Base): class CheckerModelInvocationGrant(Base): - """No plaintext capability or transcript; exact immutable callback custody only.""" + """No plaintext capability or transcript; bounded session custody only.""" __tablename__ = "checker_model_invocation_grants" __table_args__ = ( @@ -722,15 +726,9 @@ class CheckerModelInvocationGrant(Base): ), CheckConstraint( "token_hash ~ '^sha256:[0-9a-f]{64}$' and " - "external_request_digest ~ '^sha256:[0-9a-f]{64}$' and " - "(selected_request_sha256 is null or selected_request_sha256 ~ '^sha256:[0-9a-f]{64}$')", + "external_request_digest ~ '^sha256:[0-9a-f]{64}$'", name="digests", ), - CheckConstraint( - "(consumed_at is null and selected_request_sha256 is null and selected_attempt_id is null) or " - "(consumed_at is not null and selected_request_sha256 is not null and selected_attempt_id is not null)", - name="consumption", - ), ) id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) token_hash: Mapped[str] = mapped_column(String(71), nullable=False, unique=True) @@ -758,12 +756,35 @@ class CheckerModelInvocationGrant(Base): nullable=False, unique=True, ) - selected_request_sha256: Mapped[str | None] = mapped_column(String(71)) - selected_attempt_id: Mapped[str | None] = mapped_column( + + +class CheckerModelInvocationGrantCall(Base): + """One canonical call index bound to one durable paid effect under a session grant.""" + + __tablename__ = "checker_model_invocation_grant_calls" + __table_args__ = ( + CheckConstraint( + "call_index between 0 and 99 and request_sha256 ~ '^sha256:[0-9a-f]{64}$'", + name="facts", + ), + UniqueConstraint("grant_id", "call_index", name="uq_checker_model_grant_call_index"), + UniqueConstraint("attempt_id", name="uq_checker_model_grant_call_attempt"), + ) + grant_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("checker_model_invocation_grants.id", ondelete="RESTRICT"), + primary_key=True, + ) + call_index: Mapped[int] = mapped_column(Integer, primary_key=True) + attempt_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("model_usage_effects.id", ondelete="RESTRICT"), + nullable=False, + ) + request_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + bound_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.clock_timestamp(), nullable=False ) - consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) __all__ = ( @@ -776,5 +797,6 @@ class CheckerModelInvocationGrant(Base): "SetupModelUsageEnvelope", "SetupModelUsageSlot", "CheckerModelInvocationGrant", + "CheckerModelInvocationGrantCall", "ModelUsageProfile", ) diff --git a/backend/app/modules/model_usage/owner_ports.py b/backend/app/modules/model_usage/owner_ports.py index dbfdd56b7..f5ea88a10 100644 --- a/backend/app/modules/model_usage/owner_ports.py +++ b/backend/app/modules/model_usage/owner_ports.py @@ -20,6 +20,7 @@ ) from app.modules.model_usage.models import ( CheckerModelInvocationGrant, + CheckerModelInvocationGrantCall, CheckerModelUsageSlot, ModelUsageEffect, ) @@ -85,9 +86,12 @@ async def recovery_owner( slot_row = await self._repo.slot(slot) parent = await self._repo.attempt(parent_attempt_id) grant = await self._session.scalar( - select(CheckerModelInvocationGrant).where( - CheckerModelInvocationGrant.selected_attempt_id == str(parent_attempt_id) + select(CheckerModelInvocationGrant) + .join( + CheckerModelInvocationGrantCall, + CheckerModelInvocationGrantCall.grant_id == CheckerModelInvocationGrant.id, ) + .where(CheckerModelInvocationGrantCall.attempt_id == str(parent_attempt_id)) ) if ( slot_row is None @@ -95,8 +99,8 @@ async def recovery_owner( or parent.checker_slot_id != slot_row.id or grant is None or grant.project_id != str(slot.project_id) - or grant.paid_slot != slot.model_dump(mode="json") - or grant.selected_request_sha256 != parent.request_sha256 + or ModelPaidSlotKey.model_validate(grant.paid_slot).envelope_digest + != slot.envelope_digest ): raise ModelCallUnavailable("model_recovery_source_unavailable") try: @@ -126,9 +130,14 @@ async def recovery_candidates( CheckerModelUsageSlot, CheckerModelInvocationGrant.registry_entry_id, ) + .join( + CheckerModelInvocationGrantCall, + CheckerModelInvocationGrantCall.grant_id + == CheckerModelInvocationGrant.id, + ) .join( ModelUsageEffect, - ModelUsageEffect.id == CheckerModelInvocationGrant.selected_attempt_id, + ModelUsageEffect.id == CheckerModelInvocationGrantCall.attempt_id, ) .join( CheckerModelUsageSlot, diff --git a/backend/app/modules/model_usage/setup.py b/backend/app/modules/model_usage/setup.py index cdf643592..767f539ec 100644 --- a/backend/app/modules/model_usage/setup.py +++ b/backend/app/modules/model_usage/setup.py @@ -22,6 +22,7 @@ PreparedSetupModelUsageEffect, SetupModelDispatchReservation, SetupModelEffectAuthorityFacts, + SetupModelOwnerProfile, SetupModelUsageAuthorityPort, SetupModelUsageEvidencePort, SetupModelUsageOwnerPort, @@ -127,14 +128,32 @@ def __init__( self._evidence = evidence self._owner = owner - async def _profile(self, key: SetupModelUsageSlotKey): + async def _profile( + self, + key: SetupModelUsageSlotKey, + owner_profile: SetupModelOwnerProfile | None = None, + ): row = await self._repo.profile_exact( key.profile_id, key.profile_version, key.profile_digest ) if row is None: raise ModelCallUnavailable("model_profile_unavailable") profile = stored_model_profile(row) - if profile.spec.project_id != key.project_id: + if ( + profile.spec.project_id != key.project_id + or ( + owner_profile is not None + and ( + profile.selector != owner_profile.selector + or (profile.spec.provider, profile.spec.api, profile.spec.model_snapshot) + != ( + owner_profile.provider, + owner_profile.api, + owner_profile.model_snapshot, + ) + ) + ) + ): raise ModelCallUnavailable("model_profile_unavailable") return profile @@ -147,8 +166,8 @@ async def prepare( facts = {"slot": key.model_dump(mode="json"), "request_sha256": request_sha256} authority = await self._authority.authorize_setup_prepare(key, request_sha256) _require_authority("setup_prepare", facts, authority) - await self._owner.require_setup_current(key) - profile = await self._profile(key) + owner_profile = await self._owner.require_setup_current(key) + profile = await self._profile(key, owner_profile) if key.call_index >= profile.spec.maximum_calls: raise ModelCallConflict("setup_model_call_index_conflict") @@ -201,9 +220,9 @@ async def reserve_dispatch( authority = await self._authority.authorize_setup_dispatch(prepared, request_evidence) _require_authority("setup_dispatch", facts, authority) key = prepared.slot_key - await self._owner.require_setup_current(key) + owner_profile = await self._owner.require_setup_current(key) await self._evidence.require_setup_request(prepared, request_evidence) - profile = await self._profile(key) + profile = await self._profile(key, owner_profile) if ( request_evidence.content_sha256 != prepared.request_sha256 or request_evidence.byte_count > profile.spec.maximum_request_bytes diff --git a/backend/app/modules/projects/guide_compilation/model_usage.py b/backend/app/modules/projects/guide_compilation/model_usage.py index 192d5ca57..aea8859e7 100644 --- a/backend/app/modules/projects/guide_compilation/model_usage.py +++ b/backend/app/modules/projects/guide_compilation/model_usage.py @@ -3,7 +3,13 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.modules.model_usage.api import ModelCallUnavailable, SetupModelUsageSlotKey +from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration +from app.modules.model_usage.api import ( + ModelCallUnavailable, + ModelProfileSelector, + SetupModelOwnerProfile, + SetupModelUsageSlotKey, +) from app.modules.projects.guide_compilation.models import ProjectGuideCompilationAttempt @@ -13,10 +19,10 @@ class SetupModelUsageCurrent: def __init__(self, session: AsyncSession) -> None: self._session = session - async def require_setup_current(self, key: SetupModelUsageSlotKey) -> None: + async def require_setup_current(self, key: SetupModelUsageSlotKey) -> SetupModelOwnerProfile: key = SetupModelUsageSlotKey.model_validate(key) - attempt_id = await self._session.scalar( - select(ProjectGuideCompilationAttempt.id) + attempt = await self._session.scalar( + select(ProjectGuideCompilationAttempt) .where( ProjectGuideCompilationAttempt.id == key.compilation_attempt_id, ProjectGuideCompilationAttempt.project_id == str(key.project_id), @@ -31,8 +37,31 @@ async def require_setup_current(self, key: SetupModelUsageSlotKey) -> None: .with_for_update() .execution_options(populate_existing=True) ) - if attempt_id is None: + if attempt is None: raise ModelCallUnavailable("setup_model_owner_unavailable") + try: + configuration = ProjectGuideRuntimeConfiguration.model_validate( + attempt.runtime_configuration + ) + selector = ModelProfileSelector( + profile_id=configuration.model_profile_id, + profile_version=configuration.model_profile_version, + profile_digest=configuration.model_profile_digest, + ) + except ValueError as exc: + raise ModelCallUnavailable("setup_model_owner_unavailable") from exc + if selector != ModelProfileSelector( + profile_id=key.profile_id, + profile_version=key.profile_version, + profile_digest=key.profile_digest, + ): + raise ModelCallUnavailable("setup_model_owner_unavailable") + return SetupModelOwnerProfile( + selector=selector, + provider=configuration.model_provider, + api=configuration.model_api, + model_snapshot=configuration.model, + ) __all__ = ("SetupModelUsageCurrent",) diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index ab06c5992..d7c12e70f 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -493,6 +493,7 @@ class TestLane: "tests/checkers_model_calls/test_model_call_contract.py", "tests/checkers_model_calls/test_model_call_transactions_postgresql.py", "tests/checkers_model_calls/test_model_invocation_grants_postgresql.py", + "tests/checkers_model_calls/test_model_shared_setup_postgresql.py", "tests/checkers_model_calls/test_model_runtime.py", "tests/checkers_model_calls/test_proxy_orchestration.py", "tests/checkers_model_calls/test_public_api.py", diff --git a/backend/tests/checkers/external/test_model_proxy_transport.py b/backend/tests/checkers/external/test_model_proxy_transport.py index 2fb51f114..382ceff9b 100644 --- a/backend/tests/checkers/external/test_model_proxy_transport.py +++ b/backend/tests/checkers/external/test_model_proxy_transport.py @@ -30,13 +30,15 @@ RECEIPT_DIGEST = "sha256:" + "c" * 64 -def request_value(*, prompt: str = "Judge this bounded artifact.") -> dict[str, str]: +def request_value(*, prompt: str = "Judge this bounded artifact.") -> dict[str, object]: """Build the exact Rust relay request root.""" return { "protocol_version": MODEL_PROXY_PROTOCOL, "grant_id": GRANT_ID, "request_digest": REQUEST_DIGEST, + "call_index": 0, "prompt": prompt, + "resolved_receipts": [], } @@ -87,6 +89,8 @@ async def handle(request: ModelProxyRequest) -> ModelProxyReply: output="bounded result", receipt_digest=RECEIPT_DIGEST, failure_code=None, + call_index=0, + session_exhausted=False, ) transport = ModelProxyTransport(socket_path=path, handler=handle) @@ -104,19 +108,22 @@ async def handle(request: ModelProxyRequest) -> ModelProxyReply: "output": "bounded result", "receipt_digest": RECEIPT_DIGEST, "failure_code": None, + "call_index": 0, + "session_exhausted": False, } assert received == [ ModelProxyRequest( grant_id=GRANT_ID, request_digest=REQUEST_DIGEST, + call_index=0, prompt="Judge this bounded artifact.", ) ] assert GRANT_ID not in repr(received[0]) assert received[0].prompt not in repr(received[0]) assert "bounded result" not in repr( - ModelProxyReply("completed", "bounded result", RECEIPT_DIGEST, None) + ModelProxyReply("completed", "bounded result", RECEIPT_DIGEST, None, 0, False) ) assert not path.exists() @@ -131,9 +138,10 @@ async def handle(request: ModelProxyRequest) -> ModelProxyReply: {key: value for key, value in request_value().items() if key != "prompt"} ).encode(), ( - b'{"protocol_version":"workstream.checker_model_proxy.v1",' + b'{"protocol_version":"workstream.model_session.v2",' b'"grant_id":"model_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",' - b'"request_digest":"sha256:' + b"b" * 64 + b'","prompt":"first","prompt":"second"}' + b'"request_digest":"sha256:' + b"b" * 64 + b'","call_index":0,' + b'"prompt":"first","prompt":"second","resolved_receipts":[]}' ), json.dumps({**request_value(), "grant_id": "model_" + "A" * 32}).encode(), json.dumps({**request_value(), "protocol_version": "model_proxy.v0"}).encode(), @@ -147,7 +155,7 @@ async def test_malformed_or_open_request_is_closed_without_dispatch(tmp_path, pa async def handle(_: ModelProxyRequest) -> ModelProxyReply: nonlocal calls calls += 1 - return ModelProxyReply("unavailable", None, None, "provider_unavailable") + return ModelProxyReply("unavailable", None, None, "provider_unavailable", 0, True) async with ModelProxyTransport(socket_path=path, handler=handle): async with connection(path) as (reader, writer): @@ -190,6 +198,8 @@ async def handle(_: ModelProxyRequest) -> ModelProxyReply: "x" * MODEL_PROXY_MAXIMUM_FRAME_BYTES, RECEIPT_DIGEST, None, + 0, + False, ) async with ModelProxyTransport(socket_path=path, handler=handle): @@ -211,7 +221,7 @@ async def handle(_: ModelProxyRequest) -> ModelProxyReply: calls += 1 entered.set() await release.wait() - return ModelProxyReply("unavailable", None, None, "budget_unavailable") + return ModelProxyReply("unavailable", None, None, "budget_unavailable", 0, True) async with ModelProxyTransport( socket_path=path, @@ -234,6 +244,8 @@ async def handle(_: ModelProxyRequest) -> ModelProxyReply: "output": None, "receipt_digest": None, "failure_code": "budget_unavailable", + "call_index": 0, + "session_exhausted": True, } assert calls == 1 @@ -258,7 +270,7 @@ async def handle(_: ModelProxyRequest) -> ModelProxyReply: cancelled = True raise completed.set() - return ModelProxyReply("unavailable", None, None, "provider_unavailable") + return ModelProxyReply("unavailable", None, None, "provider_unavailable", 0, True) transport = ModelProxyTransport( socket_path=path, @@ -292,7 +304,7 @@ async def test_start_refuses_nonprivate_symlink_and_occupied_paths(tmp_path): public_parent.mkdir(mode=0o755) async def handle(_: ModelProxyRequest) -> ModelProxyReply: - return ModelProxyReply("unavailable", None, None, "provider_unavailable") + return ModelProxyReply("unavailable", None, None, "provider_unavailable", 0, True) with pytest.raises(ModelProxyCustodyError, match="parent custody"): await ModelProxyTransport(socket_path=public_parent / "proxy.sock", handler=handle).start() @@ -324,7 +336,7 @@ async def test_cleanup_refuses_replaced_socket_and_leaves_it_in_place(tmp_path): path = private_socket_path(tmp_path) async def handle(_: ModelProxyRequest) -> ModelProxyReply: - return ModelProxyReply("unavailable", None, None, "provider_unavailable") + return ModelProxyReply("unavailable", None, None, "provider_unavailable", 0, True) transport = ModelProxyTransport(socket_path=path, handler=handle) await transport.start() @@ -344,11 +356,11 @@ async def handle(_: ModelProxyRequest) -> ModelProxyReply: @pytest.mark.parametrize( "reply", [ - ("completed", "", RECEIPT_DIGEST, None), - ("completed", "output", None, None), - ("unavailable", "output", None, "provider_unavailable"), - ("unavailable", None, None, "Provider-Unavailable"), - ("unavailable", None, None, "a" * 65), + ("completed", "", RECEIPT_DIGEST, None, 0, False), + ("completed", "output", None, None, 0, False), + ("unavailable", "output", None, "provider_unavailable", 0, True), + ("unavailable", None, None, "Provider-Unavailable", 0, True), + ("unavailable", None, None, "a" * 65, 0, True), ], ) def test_reply_rejects_invalid_closed_outcomes(reply): diff --git a/backend/tests/checkers_model_calls/test_composed_execution.py b/backend/tests/checkers_model_calls/test_composed_execution.py index 73684ce7e..8e3839fe8 100644 --- a/backend/tests/checkers_model_calls/test_composed_execution.py +++ b/backend/tests/checkers_model_calls/test_composed_execution.py @@ -243,9 +243,10 @@ async def execute(self, request, grant, *, model_grant=None): self.invocations += 1 assert model_grant is not None callback = ModelProxyRequest( - model_grant.grant_id, - request.request_digest, - "Evaluate the verified work", + grant_id=model_grant.grant_id, + request_digest=request.request_digest, + call_index=0, + prompt="Evaluate the verified work", ) if concurrent_callback: replies = await asyncio.gather(proxy.handle(callback), proxy.handle(callback)) diff --git a/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py index de7124053..44e8b2bd3 100644 --- a/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py @@ -34,14 +34,16 @@ ModelGenerationSettings, ModelPaidSlotKey, ModelPricing, + ModelProfileSelector, ModelProfileProvisioningFacts, ProjectModelBudgetConfigurationSpec, ProjectModelProfileSpec, SetupModelUsageSlotKey, + SetupModelOwnerProfile, model_action_digest, model_retry_terminal_digest, ) -from app.modules.model_usage.models import ModelUsageBudgetAccount, ModelUsageEffect +from app.modules.model_usage.models import ModelUsageBudgetAccount from app.modules.model_usage.profiles import ModelProfileReader from app.modules.model_usage.service import ModelCallService from app.modules.model_usage.setup import SetupModelUsageService @@ -75,40 +77,27 @@ async def model_database(postgres_database_url: str): submission_id = new_record_id() registry_entry_id = new_record_id() registry_entry_digest = sha("registry") - migration = runpy.run_path( - str(Path(__file__).resolve().parents[2] / "alembic/versions/0060_model_usage.py") - ) + migration = runpy.run_path(str(Path(__file__).resolve().parents[2] / "alembic/versions/0060_model_usage.py")) def isolated(statement: str) -> str: - return statement.replace("public.", f'"{schema}".').replace( - "pg_catalog,public,pg_temp", f'pg_catalog,"{schema}",pg_temp' - ) + return statement.replace("public.", f'"{schema}".').replace("pg_catalog,public,pg_temp", f'pg_catalog,"{schema}",pg_temp') async with engine.begin() as connection: await connection.execute(text(f'create schema "{schema}"')) for statement in ( "create table projects(id uuid primary key)", - "create table workstream_tasks(id uuid primary key, project_id uuid not null, " - "unique(id,project_id))", - "create table task_assignments(id uuid primary key, task_id uuid not null, " - "project_id uuid not null)", - "create table submissions(id uuid primary key, task_id uuid not null, " - "version integer not null, unique(id,task_id,version))", - "create table external_checker_registry_entries(id uuid primary key," - "entry_digest text not null,phase text not null)", + "create table workstream_tasks(id uuid primary key, project_id uuid not null, unique(id,project_id))", + "create table task_assignments(id uuid primary key, task_id uuid not null, project_id uuid not null)", + "create table submissions(id uuid primary key, task_id uuid not null, version integer not null, unique(id,task_id,version))", + "create table external_checker_registry_entries(id uuid primary key,entry_digest text not null,phase text not null)", "create table checker_runs(id uuid primary key)", "create table actor_profiles(id uuid primary key)", - "create table audit_events(id uuid primary key, event_domain text, event_type text, " - "actor_ref_kind text, actor_id text, action_id text, permission_id text, " - "denial_code text, after_facts json)", + "create table audit_events(id uuid primary key, event_domain text, event_type text, actor_ref_kind text, actor_id text, action_id text, permission_id text, denial_code text, after_facts json)", "create table artifact_bindings(id uuid primary key)", "create table artifact_contents(id uuid primary key)", "create table artifact_put_attempts(id uuid primary key)", "create table artifact_verification_receipts(id uuid primary key)", - "create table project_guide_compilation_attempts(" - "id uuid primary key,project_id uuid not null,guide_id uuid not null," - "source_snapshot_id uuid not null,setup_run_id uuid not null," - "setup_generation bigint not null,runtime_configuration_hash text,status text)", + "create table project_guide_compilation_attempts(id uuid primary key,project_id uuid not null,guide_id uuid not null,source_snapshot_id uuid not null,setup_run_id uuid not null,setup_generation bigint not null,runtime_configuration jsonb,runtime_configuration_hash text,status text)", ): await connection.execute(text(statement)) for statement in migration["_TABLES"]: @@ -131,10 +120,7 @@ def isolated(statement: str) -> str: {"id": submission_id, "task": task_id}, ) await connection.execute( - text( - "insert into external_checker_registry_entries(id,entry_digest,phase) " - "values (:id,:digest,'post_submit')" - ), + text("insert into external_checker_registry_entries(id,entry_digest,phase) values (:id,:digest,'post_submit')"), {"id": registry_entry_id, "digest": registry_entry_digest}, ) try: @@ -169,11 +155,7 @@ class BoundaryPorts: def _decision(self, action: str, facts: object) -> ModelActionEvidence: self.events.append(action) digest = model_action_digest(action, facts) - evidence_id = ( - new_record_id() - if self.fresh_authority_evidence - else self.decisions.setdefault(digest, new_record_id()) - ) + evidence_id = new_record_id() if self.fresh_authority_evidence else self.decisions.setdefault(digest, new_record_id()) return ModelActionEvidence(evidence_id=evidence_id, facts_digest=digest) async def authorize_profile(self, facts): @@ -236,11 +218,7 @@ async def authorize_complete(self, effect, response_evidence, terminal_digest): "complete", { "effect": effect.model_dump(mode="json"), - "response_evidence": ( - response_evidence.model_dump(mode="json") - if response_evidence is not None - else None - ), + "response_evidence": (response_evidence.model_dump(mode="json") if response_evidence is not None else None), "terminal_digest": terminal_digest, }, ) @@ -275,11 +253,7 @@ async def authorize_setup_complete(self, effect, response_evidence, terminal_dig "setup_complete", { "effect": effect.model_dump(mode="json"), - "response_evidence": ( - response_evidence.model_dump(mode="json") - if response_evidence is not None - else None - ), + "response_evidence": (response_evidence.model_dump(mode="json") if response_evidence is not None else None), "terminal_digest": terminal_digest, }, ) @@ -312,9 +286,7 @@ def __init__(self, session: AsyncSession, ports: BoundaryPorts): self._session = session self._ports = ports - async def _authority( - self, evidence, *, action: str, permission: str, actor_id=None, resource_digest=None - ): + async def _authority(self, evidence, *, action: str, permission: str, actor_id=None, resource_digest=None): actor = str(actor_id or new_record_id()) if actor_id is not None: await self._session.execute( @@ -322,12 +294,7 @@ async def _authority( {"id": actor_id}, ) await self._session.execute( - text( - "insert into audit_events(id,event_domain,event_type,actor_ref_kind,actor_id," - "action_id,permission_id,denial_code,after_facts) values " - "(:id,'authority','SensitiveAuthorizationAllowed','actor_profile',:actor," - ":action,:permission,null,cast(:after as json))" - ), + text("insert into audit_events(id,event_domain,event_type,actor_ref_kind,actor_id,action_id,permission_id,denial_code,after_facts) values (:id,'authority','SensitiveAuthorizationAllowed','actor_profile',:actor,:action,:permission,null,cast(:after as json))"), { "id": evidence.evidence_id, "actor": actor, @@ -336,11 +303,7 @@ async def _authority( "after": json.dumps( { "allowed": True, - "resource_context_digest": ( - sha("substituted-authority") - if actor_id is not None and self._ports.corrupt_admin_receipt - else resource_digest or sha("authority") - ), + "resource_context_digest": (sha("substituted-authority") if actor_id is not None and self._ports.corrupt_admin_receipt else resource_digest or sha("authority")), } ), }, @@ -354,11 +317,7 @@ async def authorize_profile(self, facts): action="project.model_profile.provision", permission="project.model_profile.manage", actor_id=facts.actor_profile_id, - resource_digest=authorization_resource_digest( - project_model_admin_resource( - PROFILE_PROVISION, facts, actor_profile_id=facts.actor_profile_id - ) - ), + resource_digest=authorization_resource_digest(project_model_admin_resource(PROFILE_PROVISION, facts, actor_profile_id=facts.actor_profile_id)), ) async def authorize_budget(self, facts): @@ -368,18 +327,12 @@ async def authorize_budget(self, facts): action="project.model_budget.configure", permission="project.model_budget.manage", actor_id=facts.actor_profile_id, - resource_digest=authorization_resource_digest( - project_model_admin_resource( - BUDGET_CONFIGURE, facts, actor_profile_id=facts.actor_profile_id - ) - ), + resource_digest=authorization_resource_digest(project_model_admin_resource(BUDGET_CONFIGURE, facts, actor_profile_id=facts.actor_profile_id)), ) async def authorize_prepare(self, execution, slot, request_sha256): evidence = await self._ports.authorize_prepare(execution, slot, request_sha256) - return await self._authority( - evidence, action="checker.model_call.prepare", permission="checker.model_call.prepare" - ) + return await self._authority(evidence, action="checker.model_call.prepare", permission="checker.model_call.prepare") async def authorize_dispatch(self, execution, attempt, request_evidence): evidence = await self._ports.authorize_dispatch(execution, attempt, request_evidence) @@ -391,17 +344,11 @@ async def authorize_dispatch(self, execution, attempt, request_evidence): async def authorize_recover(self, execution, effect): evidence = await self._ports.authorize_recover(execution, effect) - return await self._authority( - evidence, action="checker.model_call.recover", permission="checker.model_call.recover" - ) + return await self._authority(evidence, action="checker.model_call.recover", permission="checker.model_call.recover") async def authorize_observe(self, effect, provider_response_id, observation_digest): - evidence = await self._ports.authorize_observe( - effect, provider_response_id, observation_digest - ) - return await self._authority( - evidence, action="checker.model_call.observe", permission="checker.model_call.observe" - ) + evidence = await self._ports.authorize_observe(effect, provider_response_id, observation_digest) + return await self._authority(evidence, action="checker.model_call.observe", permission="checker.model_call.observe") async def authorize_child(self, project_id, parent_attempt_id, terminal_digest): evidence = await self._ports.authorize_child(project_id, parent_attempt_id, terminal_digest) @@ -436,9 +383,7 @@ async def authorize_setup_dispatch(self, prepared, request_evidence): ) async def authorize_setup_observe(self, effect, provider_response_id, observation_digest): - evidence = await self._ports.authorize_setup_observe( - effect, provider_response_id, observation_digest - ) + evidence = await self._ports.authorize_setup_observe(effect, provider_response_id, observation_digest) return await self._authority( evidence, action="project.guide_compilation.model.observe", @@ -446,9 +391,7 @@ async def authorize_setup_observe(self, effect, provider_response_id, observatio ) async def authorize_setup_complete(self, effect, response_evidence, terminal_digest): - evidence = await self._ports.authorize_setup_complete( - effect, response_evidence, terminal_digest - ) + evidence = await self._ports.authorize_setup_complete(effect, response_evidence, terminal_digest) return await self._authority( evidence, action="project.guide_compilation.model.complete", @@ -465,13 +408,7 @@ async def require_setup_response(self, effect_id, evidence): async def require_setup_current(self, slot): attempt_id = await self._session.scalar( - text( - "select id from project_guide_compilation_attempts " - "where id=:id and project_id=:project and guide_id=:guide " - "and source_snapshot_id=:snapshot and setup_run_id=:run " - "and setup_generation=:generation and runtime_configuration_hash=:runtime " - "and status='compilation_reserved' for update" - ), + text("select id from project_guide_compilation_attempts where id=:id and project_id=:project and guide_id=:guide and source_snapshot_id=:snapshot and setup_run_id=:run and setup_generation=:generation and runtime_configuration_hash=:runtime and status='compilation_reserved' for update"), { "id": slot.compilation_attempt_id, "project": slot.project_id, @@ -484,6 +421,16 @@ async def require_setup_current(self, slot): ) if attempt_id is None: raise ModelCallUnavailable("setup_model_owner_unavailable") + return SetupModelOwnerProfile( + selector=ModelProfileSelector( + profile_id=slot.profile_id, + profile_version=slot.profile_version, + profile_digest=slot.profile_digest, + ), + provider="openai_responses", + api="responses.v1", + model_snapshot="gpt-example-2026-10-01", + ) async def require_current(self, facts, slot): await self._ports.require_current(facts, slot) @@ -542,21 +489,25 @@ async def setup_slot_key( guide_id = new_record_id() snapshot_id = new_record_id() setup_run_id = new_record_id() - runtime_hash = sha("setup-runtime") + runtime_configuration = { + "model_provider": profile.spec.provider, + "model_api": profile.spec.api, + "model": profile.spec.model_snapshot, + "model_profile_id": str(profile.profile_id), + "model_profile_version": profile.spec.profile_version, + "model_profile_digest": profile.profile_digest, + } + runtime_hash = canonical_json_hash(runtime_configuration) async with facts.engine.begin() as connection: await connection.execute( - text( - "insert into project_guide_compilation_attempts(" - "id,project_id,guide_id,source_snapshot_id,setup_run_id," - "setup_generation,runtime_configuration_hash,status) values(" - ":id,:project,:guide,:snapshot,:run,1,:runtime,'compilation_reserved')" - ), + text("insert into project_guide_compilation_attempts(id,project_id,guide_id,source_snapshot_id,setup_run_id,setup_generation,runtime_configuration,runtime_configuration_hash,status) values(:id,:project,:guide,:snapshot,:run,1,cast(:configuration as jsonb),:runtime,'compilation_reserved')"), { "id": attempt_id, "project": facts.project_id, "guide": guide_id, "snapshot": snapshot_id, "run": setup_run_id, + "configuration": json.dumps(runtime_configuration), "runtime": runtime_hash, }, ) @@ -643,94 +594,11 @@ async def provision( if replay: replayed_profile = await owner.provision_profile(profile_facts) replayed_budget = await owner.provision_budget(budget_facts) - assert replayed_profile.authorization_evidence_id == ( - profile.authorization_evidence_id - ) - assert replayed_budget.authorization_evidence_id == ( - budget.authorization_evidence_id - ) + assert replayed_profile.authorization_evidence_id == (profile.authorization_evidence_id) + assert replayed_budget.authorization_evidence_id == (budget.authorization_evidence_id) return profile -async def test_provisioning_replay_retains_original_receipts_with_fresh_authority( - model_database: DatabaseFacts, -) -> None: - await provision(model_database, BoundaryPorts(), limit=10_000, replay=True) - - -async def test_profile_publication_rejects_substituted_authority_digest( - model_database: DatabaseFacts, -) -> None: - with pytest.raises(DBAPIError): - await provision( - model_database, - BoundaryPorts(corrupt_admin_receipt=True), - limit=10_000, - ) - - -async def test_profile_publication_is_not_owned_by_a_checker_registry_entry( - model_database: DatabaseFacts, -) -> None: - async with model_database.engine.begin() as connection: - await connection.execute( - text("update external_checker_registry_entries set phase='pre_submit' where id=:id"), - {"id": model_database.registry_entry_id}, - ) - profile = await provision(model_database, BoundaryPorts(), limit=10_000) - assert profile.spec.project_id == model_database.project_id - - -async def test_database_guards_recompute_profile_and_budget_digests( - model_database: DatabaseFacts, -) -> None: - await provision(model_database, BoundaryPorts(), limit=10_000) - async with AsyncSession(model_database.engine) as session: - with pytest.raises(DBAPIError, match="profile digest is invalid"): - async with session.begin(): - await session.execute( - text( - "insert into model_usage_profiles select " - ":id,project_id,2,:digest,provider,api,model_snapshot," - "generation_settings,maximum_request_bytes," - "maximum_output_bytes,maximum_response_bytes,maximum_input_tokens," - "maximum_output_tokens,maximum_calls,maximum_total_request_bytes," - "maximum_total_response_bytes,maximum_total_input_tokens," - "maximum_total_output_tokens,allowed_tools,maximum_tool_calls," - "maximum_tool_output_bytes,deadline_ms,pricing_policy_id," - "pricing_policy_version,pricing_policy_digest,uncached_input_rate," - "cached_input_rate,output_rate,:operation,actor_profile_id,:authority," - "clock_timestamp() from model_usage_profiles where profile_version=1" - ), - { - "id": new_record_id(), - "digest": sha("noncanonical-profile"), - "operation": uuid4(), - "authority": new_record_id(), - }, - ) - - async with AsyncSession(model_database.engine) as session: - with pytest.raises(DBAPIError, match="configuration digest is invalid"): - async with session.begin(): - await session.execute( - text( - "insert into model_usage_budget_configurations select " - ":id,project_id,2,:digest,authorized_total_nanodollars," - "externally_reconciled_prior_spend_nanodollars," - "lifetime_limit_nanodollars,:operation," - "actor_profile_id,:authority,clock_timestamp() " - "from model_usage_budget_configurations where configuration_version=1" - ), - { - "id": new_record_id(), - "digest": sha("noncanonical-budget"), - "operation": uuid4(), - "authority": new_record_id(), - }, - ) - - def execution(facts: DatabaseFacts) -> ModelCallExecutionFacts: return ModelCallExecutionFacts( project_id=facts.project_id, @@ -782,9 +650,7 @@ async def wait_until_blocked_by( """Observe an actual PostgreSQL lock wait instead of inferring final totals.""" for _ in range(200): async with engine.connect() as connection: - blockers = await connection.scalar( - text("select pg_blocking_pids(:pid)"), {"pid": blocked_pid} - ) + blockers = await connection.scalar(text("select pg_blocking_pids(:pid)"), {"pid": blocked_pid}) if blocker_pid in blockers: return await asyncio.sleep(0.01) @@ -814,497 +680,31 @@ async def prepare_and_reserve( return run, paid_slot, prepared, reservation, request_evidence -async def test_concurrent_dispatch_reserves_one_lifetime_budget( - model_database: DatabaseFacts, -) -> None: - ports = BoundaryPorts() - reservation = 100 * 11 + 50 * 29 - profile = await provision(model_database, ports, limit=reservation) - prepared = [] - for requirement in ("model_a", "model_b"): - run = execution(model_database) - paid_slot = slot(model_database, profile, requirement) - request_sha = sha(requirement) - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - item = await service(session, ports).prepare_attempt( - run, - paid_slot, - registry_entry_id=model_database.registry_entry_id, - request_sha256=request_sha, - ) - prepared.append((run, item, evidence(requirement, request_sha))) - - async def dispatch(item): - run, attempt, request_evidence = item - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - return await service(session, ports).reserve_dispatch( - run, attempt, request_evidence - ) - - results = await asyncio.gather(*(dispatch(item) for item in prepared), return_exceptions=True) - assert sum(not isinstance(result, BaseException) for result in results) == 1 - assert sum(isinstance(result, ModelCallBudgetUnavailable) for result in results) == 1 - async with AsyncSession(model_database.engine) as session: - account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) - assert account.lifetime_reserved_nanodollars == reservation - assert account.lifetime_settled_nanodollars == 0 - - -async def test_setup_and_checker_dispatches_share_one_account_and_both_win( - model_database: DatabaseFacts, -) -> None: - ports = BoundaryPorts() - per_dispatch = 100 * 11 + 50 * 29 - profile = await provision( - model_database, - ports, - limit=per_dispatch * 2, - profile_changes={ - "maximum_calls": 2, - "maximum_total_request_bytes": 20_000, - "maximum_total_response_bytes": 60_000, - "maximum_total_input_tokens": 200, - "maximum_total_output_tokens": 100, - }, - ) - checker_run = execution(model_database) - checker_slot = slot(model_database, profile, "shared_budget_checker") - checker_sha = sha("shared-budget-checker") - checker_evidence = evidence("shared-budget-checker", checker_sha) - setup_key = await setup_slot_key(model_database, profile) - setup_sha = sha("shared-budget-setup") - setup_evidence = evidence("shared-budget-setup", setup_sha) - - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - checker_prepared = await service(session, ports).prepare_attempt( - checker_run, - checker_slot, - registry_entry_id=model_database.registry_entry_id, - request_sha256=checker_sha, - ) - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - setup_prepared = await setup_service(session, ports).prepare(setup_key, setup_sha) - - async def reserve_checker(): - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - return await service(session, ports).reserve_dispatch( - checker_run, checker_prepared, checker_evidence - ) - - async def reserve_setup(): - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - return await setup_service(session, ports).reserve_dispatch( - setup_prepared, setup_evidence - ) - - checker_reserved, setup_reserved = await asyncio.gather(reserve_checker(), reserve_setup()) - assert checker_reserved.reserved_nanodollars == per_dispatch - assert setup_reserved.reserved_nanodollars == per_dispatch - async with AsyncSession(model_database.engine) as session: - account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) - assert account.lifetime_reserved_nanodollars == per_dispatch * 2 - assert account.lifetime_settled_nanodollars == 0 - - -async def test_cheap_completed_call_reserves_remaining_aggregate_before_next_dispatch( - model_database: DatabaseFacts, -) -> None: - """A cheap first result cannot defer an aggregate-token failure until after I/O.""" - ports = BoundaryPorts() - per_dispatch = 100 * 11 + 50 * 29 - profile = await provision( - model_database, - ports, - limit=per_dispatch * 2, - profile_changes={ - "maximum_calls": 2, - "maximum_total_request_bytes": 20_000, - "maximum_total_response_bytes": 60_000, - "maximum_total_input_tokens": 100, - "maximum_total_output_tokens": 100, - }, - ) - run = execution(model_database) - first_slot = slot(model_database, profile, "aggregate_pre_dispatch") - second_slot = first_slot.model_copy(update={"slot_index": 1}) - first_sha = sha("aggregate-pre-dispatch-first") - second_sha = sha("aggregate-pre-dispatch-second") - - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - owner = service(session, ports) - first_prepared = await owner.prepare_attempt( - run, - first_slot, - registry_entry_id=model_database.registry_entry_id, - request_sha256=first_sha, - ) - first_dispatched = await owner.reserve_dispatch( - run, - first_prepared, - evidence("aggregate-pre-dispatch-first", first_sha), - ) - await owner.complete( - first_dispatched.effect, - provider_response_id="resp_cheap_cached", - provider_model_snapshot=profile.spec.model_snapshot, - response_evidence=evidence( - "aggregate-pre-dispatch-response", - sha("aggregate-pre-dispatch-response"), - ), - usage=ModelCallUsage( - input_tokens=10, - cached_input_tokens=10, - output_tokens=0, - reasoning_tokens=0, - ), - ) - second_prepared = await owner.prepare_attempt( - run, - second_slot, - registry_entry_id=model_database.registry_entry_id, - request_sha256=second_sha, - ) - with pytest.raises(ModelCallBudgetUnavailable, match="run_envelope"): - await owner.reserve_dispatch( - run, - second_prepared, - evidence("aggregate-pre-dispatch-second", second_sha), - ) - - async with AsyncSession(model_database.engine) as session: - account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) - second = await session.get(ModelUsageEffect, str(second_prepared.attempt_id)) - assert account.lifetime_reserved_nanodollars == 0 - assert account.lifetime_settled_nanodollars == 10 * 2 - assert second is not None and second.state == "prepared" - - -async def test_setup_draft_review_correction_settle_three_shared_calls( - model_database: DatabaseFacts, -) -> None: - """One exact setup attempt can run a finite three-call owner-controlled loop.""" - ports = BoundaryPorts() - per_dispatch = 100 * 11 + 50 * 29 - profile = await provision( - model_database, - ports, - limit=per_dispatch * 3, - profile_changes={ - "maximum_calls": 3, - "maximum_total_request_bytes": 30_000, - "maximum_total_response_bytes": 90_000, - "maximum_total_input_tokens": 300, - "maximum_total_output_tokens": 150, - }, - ) - base = await setup_slot_key(model_database, profile) - settled = 0 - for index, phase in enumerate(("draft", "review", "correction")): - key = base.model_copy(update={"phase": phase, "call_index": index}) - request_sha = sha(f"setup-loop-{phase}-request") - response_sha = sha(f"setup-loop-{phase}-response") - usage = ModelCallUsage( - input_tokens=10 + index, - cached_input_tokens=index, - output_tokens=5 + index, - reasoning_tokens=index, - ) - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - owner = setup_service(session, ports) - prepared = await owner.prepare(key, request_sha) - dispatched = await owner.reserve_dispatch( - prepared, evidence(f"setup-loop-{phase}-request", request_sha) - ) - completed = await owner.complete( - dispatched.effect, - provider_response_id=f"resp_setup_{phase}", - provider_model_snapshot=profile.spec.model_snapshot, - response_evidence=evidence(f"setup-loop-{phase}-response", response_sha), - usage=usage, - ) - assert completed.effect.slot_key == key - assert completed.state == "completed" - assert completed.replayed is False - settled += completed.settled_nanodollars - - async with AsyncSession(model_database.engine) as session: - account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) - states = ( - ( - await session.execute( - text( - "select effect.state from model_usage_effects effect " - "join setup_model_usage_slots slot on slot.id=effect.setup_slot_id " - "where slot.compilation_attempt_id=:attempt order by slot.call_index" - ), - {"attempt": base.compilation_attempt_id}, - ) - ) - .scalars() - .all() +async def finish_for_lock_order(session, ports, dispatched, profile, terminal_kind): + """Run either supported terminal mutation behind the same parent lock.""" + owner = service(session, ports) + if terminal_kind == "complete": + await owner.complete( + dispatched.effect, + provider_response_id="resp_lock_order", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=evidence("terminal-order-response", sha("terminal-order-response")), + usage=ModelCallUsage( + input_tokens=40, + cached_input_tokens=10, + output_tokens=12, + reasoning_tokens=7, + ), ) - assert states == ["completed", "completed", "completed"] - assert account.lifetime_reserved_nanodollars == 0 - assert account.lifetime_settled_nanodollars == settled - - -async def test_uncertain_setup_effect_replay_never_reauthorizes_dispatch( - model_database: DatabaseFacts, -) -> None: - ports = BoundaryPorts() - reservation = 100 * 11 + 50 * 29 - profile = await provision(model_database, ports, limit=reservation) - key = await setup_slot_key(model_database, profile) - request_sha = sha("setup-uncertain-request") - request_evidence = evidence("setup-uncertain-request", request_sha) - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - owner = setup_service(session, ports) - prepared = await owner.prepare(key, request_sha) - dispatched = await owner.reserve_dispatch(prepared, request_evidence) - observed = await owner.mark_uncertain( - dispatched.effect, - sha("setup-uncertain-observation"), - ) - assert observed.state == "uncertain" - - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - owner = setup_service(session, ports) - replayed = await owner.prepare(key, request_sha) - with pytest.raises(ModelCallConflict, match="not_dispatchable"): - await owner.reserve_dispatch(replayed, request_evidence) - account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) - assert account.lifetime_reserved_nanodollars == reservation - assert account.lifetime_settled_nanodollars == 0 - - -async def test_setup_and_checker_dispatches_share_one_account_cap( - model_database: DatabaseFacts, -) -> None: - ports = BoundaryPorts() - per_dispatch = 100 * 11 + 50 * 29 - profile = await provision( - model_database, - ports, - limit=per_dispatch, - profile_changes={ - "maximum_calls": 2, - "maximum_total_request_bytes": 20_000, - "maximum_total_response_bytes": 60_000, - "maximum_total_input_tokens": 200, - "maximum_total_output_tokens": 100, - }, - ) - checker_run = execution(model_database) - checker_slot = slot(model_database, profile, "shared_cap_checker") - checker_sha = sha("shared-cap-checker") - checker_evidence = evidence("shared-cap-checker", checker_sha) - setup_key = await setup_slot_key(model_database, profile) - setup_sha = sha("shared-cap-setup") - setup_evidence = evidence("shared-cap-setup", setup_sha) - - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - checker_prepared = await service(session, ports).prepare_attempt( - checker_run, - checker_slot, - registry_entry_id=model_database.registry_entry_id, - request_sha256=checker_sha, - ) - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - setup_prepared = await setup_service(session, ports).prepare(setup_key, setup_sha) - - async def reserve_checker(): - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - return await service(session, ports).reserve_dispatch( - checker_run, checker_prepared, checker_evidence - ) - - async def reserve_setup(): - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - return await setup_service(session, ports).reserve_dispatch( - setup_prepared, setup_evidence - ) - - results = await asyncio.gather(reserve_checker(), reserve_setup(), return_exceptions=True) - assert sum(not isinstance(result, BaseException) for result in results) == 1 - assert sum(isinstance(result, ModelCallBudgetUnavailable) for result in results) == 1 - async with AsyncSession(model_database.engine) as session: - account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) - assert account.lifetime_reserved_nanodollars == per_dispatch - assert account.lifetime_settled_nanodollars == 0 - - -async def test_setup_reservation_rollback_releases_shared_account( - model_database: DatabaseFacts, -) -> None: - ports = BoundaryPorts() - per_dispatch = 100 * 11 + 50 * 29 - profile = await provision(model_database, ports, limit=per_dispatch) - setup_key = await setup_slot_key(model_database, profile) - setup_sha = sha("rollback-shared-setup") - setup_evidence = evidence("rollback-shared-setup", setup_sha) - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - setup_prepared = await setup_service(session, ports).prepare(setup_key, setup_sha) - with pytest.raises(RuntimeError, match="force setup rollback"): - async with session.begin(): - await setup_service(session, ports).reserve_dispatch(setup_prepared, setup_evidence) - raise RuntimeError("force setup rollback") - - checker_run = execution(model_database) - checker_slot = slot(model_database, profile, "after_setup_rollback") - checker_sha = sha("after-setup-rollback") - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - checker_prepared = await service(session, ports).prepare_attempt( - checker_run, - checker_slot, - registry_entry_id=model_database.registry_entry_id, - request_sha256=checker_sha, - ) - reserved = await service(session, ports).reserve_dispatch( - checker_run, - checker_prepared, - evidence("after-setup-rollback", checker_sha), - ) - assert reserved.reserved_nanodollars == per_dispatch - account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) - assert account.lifetime_reserved_nanodollars == per_dispatch - assert account.lifetime_settled_nanodollars == 0 - - -async def test_fresh_execution_authority_precedes_currentness_and_owner_locks( - model_database: DatabaseFacts, -) -> None: - ports = BoundaryPorts() - reservation = 100 * 11 + 50 * 29 - profile = await provision(model_database, ports, limit=reservation * 2) - run = execution(model_database) - paid_slot = slot(model_database, profile, "model_order") - request_sha = sha("ordered-request") - ports.events.clear() - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - owner = service(session, ports) - prepared = await owner.prepare_attempt( - run, - paid_slot, - registry_entry_id=model_database.registry_entry_id, - request_sha256=request_sha, - ) - assert ports.events == ["prepare", "current"] - ports.events.clear() - dispatched = await owner.reserve_dispatch( - run, - prepared, - evidence("ordered-request", request_sha), - ) - assert ports.events == ["dispatch", "current"] - await owner.mark_uncertain( - dispatched.effect, - sha("ordered-uncertain"), - provider_response_id="resp_ordered", - ) - - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - await session.execute( - text("insert into checker_runs(id) values (:id)"), - {"id": run.checker_run_id}, - ) - owner = service(session, ports) - ports.events.clear() - await owner.attach_recovery(run, dispatched.effect) - assert ports.events == ["recover", "current"] - ports.events.clear() - with pytest.raises(ModelCallConflict, match="not_retryable"): - await owner.prepare_recovery_child( - paid_slot, - registry_entry_id=model_database.registry_entry_id, - parent_attempt_id=prepared.attempt_id, - terminal_digest=sha("ordered-uncertain"), - request_sha256=prepared.request_sha256, - ) - assert ports.events == ["child", "recovery_current"] - - -async def test_reservation_rollback_and_completion_settle_exactly_once( - model_database: DatabaseFacts, -) -> None: - ports = BoundaryPorts() - reservation = 100 * 11 + 50 * 29 - profile = await provision(model_database, ports, limit=reservation * 2) - run = execution(model_database) - paid_slot = slot(model_database, profile, "model_rollback") - request_sha = sha("rollback-request") - request_art = evidence("request", request_sha) - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - prepared = await service(session, ports).prepare_attempt( - run, - paid_slot, - registry_entry_id=model_database.registry_entry_id, - request_sha256=request_sha, - ) - with pytest.raises(RuntimeError, match="force rollback"): - async with session.begin(): - await service(session, ports).reserve_dispatch(run, prepared, request_art) - raise RuntimeError("force rollback") - async with session.begin(): - dispatched = await service(session, ports).reserve_dispatch(run, prepared, request_art) - - usage = ModelCallUsage( - input_tokens=40, - cached_input_tokens=10, - output_tokens=12, - reasoning_tokens=7, + return + await owner.mark_known_no_effect( + dispatched.effect, + KnownNoEffectProof( + proof_id=new_record_id(), + proof_digest=sha("terminal-order-no-effect"), + observed_at=datetime.now(UTC), + ), ) - response_art = evidence("response", sha("response")) - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - owner = service(session, ports) - completed = await owner.complete( - dispatched.effect, - provider_response_id="resp_exact_once", - provider_model_snapshot=profile.spec.model_snapshot, - response_evidence=response_art, - usage=usage, - ) - replay = await owner.complete( - dispatched.effect, - provider_response_id="resp_exact_once", - provider_model_snapshot=profile.spec.model_snapshot, - response_evidence=response_art, - usage=usage, - ) - account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) - assert account.lifetime_reserved_nanodollars == 0 - assert account.lifetime_settled_nanodollars == completed.settled_nanodollars - assert replay.replayed - - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - await session.execute( - text("insert into checker_runs(id) values (:id)"), - {"id": run.checker_run_id}, - ) - ports.events.clear() - attached = await service(session, ports).attach_completed(run, dispatched.effect) - assert attached.replayed is False - assert ports.events == ["recover", "current"] @pytest.mark.parametrize("terminal_kind", ["complete", "known_no_effect"]) @@ -1365,41 +765,13 @@ async def test_terminal_holds_envelope_before_other_slot_and_account( terminal_pid = await terminal.scalar(text("select pg_backend_pid()")) contender_pid = await contender.scalar(text("select pg_backend_pid()")) await holder.execute( - text( - "select project_id from model_usage_budget_accounts " - "where project_id=:project_id for update" - ), + text("select project_id from model_usage_budget_accounts where project_id=:project_id for update"), {"project_id": model_database.project_id}, ) - if terminal_kind == "complete": - terminal_task = asyncio.create_task( - service(terminal, ports).complete( - first_dispatched.effect, - provider_response_id="resp_lock_order", - provider_model_snapshot=profile.spec.model_snapshot, - response_evidence=evidence( - "terminal-order-response", sha("terminal-order-response") - ), - usage=ModelCallUsage( - input_tokens=40, - cached_input_tokens=10, - output_tokens=12, - reasoning_tokens=7, - ), - ) - ) - else: - terminal_task = asyncio.create_task( - service(terminal, ports).mark_known_no_effect( - first_dispatched.effect, - KnownNoEffectProof( - proof_id=new_record_id(), - proof_digest=sha("terminal-order-no-effect"), - observed_at=datetime.now(UTC), - ), - ) - ) + terminal_task = asyncio.create_task( + finish_for_lock_order(terminal, ports, first_dispatched, profile, terminal_kind) + ) await wait_until_blocked_by( model_database.engine, blocked_pid=terminal_pid, @@ -1462,9 +834,7 @@ async def test_uncertain_holds_budget_and_only_proven_no_effect_allows_child( ports = BoundaryPorts() reservation = 100 * 11 + 50 * 29 profile = await provision(model_database, ports, limit=reservation * 2) - run, paid_slot, prepared, dispatched, _ = await prepare_and_reserve( - model_database, ports, profile, "model_recovery" - ) + run, paid_slot, prepared, dispatched, _ = await prepare_and_reserve(model_database, ports, profile, "model_recovery") uncertainty = sha("uncertain-delivery") async with AsyncSession(model_database.engine, expire_on_commit=False) as session: async with session.begin(): @@ -1493,9 +863,7 @@ async def test_uncertain_holds_budget_and_only_proven_no_effect_allows_child( assert account.lifetime_reserved_nanodollars == reservation # A distinct call proves the only zero-cost child path: authenticated no effect. - run2, paid_slot2, prepared2, dispatched2, _ = await prepare_and_reserve( - model_database, ports, profile, "model_no_effect" - ) + run2, paid_slot2, prepared2, dispatched2, _ = await prepare_and_reserve(model_database, ports, profile, "model_no_effect") proof = KnownNoEffectProof( proof_id=new_record_id(), proof_digest=sha("provider-no-effect"), @@ -1532,9 +900,7 @@ async def test_budget_adjustment_preserves_reserved_and_settles_old_snapshot( ports = BoundaryPorts() reservation = 100 * 11 + 50 * 29 profile = await provision(model_database, ports, limit=reservation) - _run, _paid_slot, _prepared, dispatched, _ = await prepare_and_reserve( - model_database, ports, profile, "model_budget_adjustment" - ) + _run, _paid_slot, _prepared, dispatched, _ = await prepare_and_reserve(model_database, ports, profile, "model_budget_adjustment") adjusted_spec = ProjectModelBudgetConfigurationSpec( project_id=model_database.project_id, configuration_version=2, @@ -1556,9 +922,7 @@ async def test_budget_adjustment_preserves_reserved_and_settles_old_snapshot( # The explicitly increased ceiling admits another dispatch without resetting # the first reservation or counting ledger usage as external prior spend. - _second = await prepare_and_reserve( - model_database, ports, profile, "model_budget_adjustment_second" - ) + _second = await prepare_and_reserve(model_database, ports, profile, "model_budget_adjustment_second") too_low_spec = ProjectModelBudgetConfigurationSpec( project_id=model_database.project_id, @@ -1650,9 +1014,7 @@ async def test_known_failed_settles_once_and_allows_authorized_child( ports = BoundaryPorts() reservation = 100 * 11 + 50 * 29 profile = await provision(model_database, ports, limit=reservation * 2) - run, paid_slot, prepared, dispatched, _ = await prepare_and_reserve( - model_database, ports, profile, "model_known_failure" - ) + run, paid_slot, prepared, dispatched, _ = await prepare_and_reserve(model_database, ports, profile, "model_known_failure") usage = ModelCallUsage( input_tokens=22, cached_input_tokens=2, @@ -1697,18 +1059,13 @@ async def test_database_guards_reject_ledger_mutation_and_counter_substitution( ports = BoundaryPorts() reservation = 100 * 11 + 50 * 29 profile = await provision(model_database, ports, limit=reservation * 2) - _, _, _, dispatched, _ = await prepare_and_reserve( - model_database, ports, profile, "model_guarded" - ) + _, _, _, dispatched, _ = await prepare_and_reserve(model_database, ports, profile, "model_guarded") async with AsyncSession(model_database.engine) as session: with pytest.raises(DBAPIError): async with session.begin(): await session.execute( - text( - "update checker_model_usage_slots set first_request_sha256=:digest " - "where id=:slot_id" - ), + text("update checker_model_usage_slots set first_request_sha256=:digest where id=:slot_id"), {"digest": sha("substituted"), "slot_id": dispatched.slot_id}, ) @@ -1716,11 +1073,7 @@ async def test_database_guards_reject_ledger_mutation_and_counter_substitution( with pytest.raises(DBAPIError): async with session.begin(): await session.execute( - text( - "update model_usage_budget_accounts " - "set lifetime_reserved_nanodollars=lifetime_reserved_nanodollars-1 " - "where project_id=:project_id" - ), + text("update model_usage_budget_accounts set lifetime_reserved_nanodollars=lifetime_reserved_nanodollars-1 where project_id=:project_id"), {"project_id": model_database.project_id}, ) @@ -1735,9 +1088,7 @@ async def test_database_ancestry_prevents_receipt_and_artifact_deletion( ) -> None: ports = BoundaryPorts() profile = await provision(model_database, ports, limit=10_000) - _, _, _, _, request = await prepare_and_reserve( - model_database, ports, profile, "model_ancestry" - ) + _, _, _, _, request = await prepare_and_reserve(model_database, ports, profile, "model_ancestry") for statement, identifier in ( ("delete from actor_profiles where id=:id", profile.actor_profile_id), ("delete from audit_events where id=:id", profile.authorization_evidence_id), @@ -1774,8 +1125,6 @@ async def test_profile_reader_requires_exact_project_and_selector( await reader.read_exact(profile.selector, project_id=new_record_id()) with pytest.raises(ModelCallUnavailable, match="model_profile_unavailable"): await reader.read_exact( - profile.selector.model_copy( - update={"profile_digest": sha("substituted-profile")} - ), + profile.selector.model_copy(update={"profile_digest": sha("substituted-profile")}), project_id=model_database.project_id, ) diff --git a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py index ccb47c63c..b90b46731 100644 --- a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py @@ -1,4 +1,4 @@ -"""Real PostgreSQL proof for one-use checker model invocation grants.""" +"""Real PostgreSQL proof for bounded multi-call checker model invocation grants.""" from __future__ import annotations @@ -18,6 +18,7 @@ from app.modules.model_usage.api import ( ModelActionEvidence, ModelCallExecutionFacts, + ModelCallUnavailable, ModelPaidSlotKey, model_action_digest, ) @@ -146,7 +147,18 @@ async def grant_database(ledger_database_fixture: DatabaseFacts) -> GrantDatabas for statement in capture.statements: await connection.execute(text(_isolated(statement, schema))) - profile = await provision(ledger_database, BoundaryPorts(), limit=100_000) + profile = await provision( + ledger_database, + BoundaryPorts(), + limit=100_000, + profile_changes={ + "maximum_calls": 3, + "maximum_total_request_bytes": 30_000, + "maximum_total_response_bytes": 90_000, + "maximum_total_input_tokens": 300, + "maximum_total_output_tokens": 150, + }, + ) current = execution(ledger_database) paid_slot = slot(ledger_database, profile, "model_judge") request_sha256 = sha("prepared request") @@ -235,7 +247,7 @@ async def _issue( ) -async def test_grant_round_trip_consumes_once_and_becomes_immutable( +async def test_grant_round_trip_binds_exact_calls_and_becomes_immutable( grant_database: GrantDatabase, ) -> None: async with AsyncSession(grant_database.facts.engine, expire_on_commit=False) as session: @@ -254,6 +266,14 @@ async def test_grant_round_trip_consumes_once_and_becomes_immutable( facts = await owner.resolve(grant.grant_id, grant.binding_digest) await owner.consume_prepared( facts, + call_index=0, + request_sha256=grant_database.prepared_request_sha256, + prepared_attempt_id=grant_database.prepared_attempt_id, + ) + # An exact transport replay is idempotent and does not mint another binding. + await owner.consume_prepared( + facts, + call_index=0, request_sha256=grant_database.prepared_request_sha256, prepared_attempt_id=grant_database.prepared_attempt_id, ) @@ -261,25 +281,53 @@ async def test_grant_round_trip_consumes_once_and_becomes_immutable( ( await session.execute( text( - "select selected_request_sha256,selected_attempt_id,consumed_at," - "issued_at,expires_at,token_hash from " - "checker_model_invocation_grants" + "select c.request_sha256,c.attempt_id,c.bound_at," + "g.issued_at,g.expires_at,g.token_hash,g.id as grant_id from " + "checker_model_invocation_grant_calls c join " + "checker_model_invocation_grants g on g.id=c.grant_id" ) ) ) .mappings() .one() ) - assert row.selected_request_sha256 == grant_database.prepared_request_sha256 - assert row.selected_attempt_id == grant_database.prepared_attempt_id - assert row.issued_at < row.consumed_at < row.expires_at + assert row.request_sha256 == grant_database.prepared_request_sha256 + assert row.attempt_id == grant_database.prepared_attempt_id + assert row.issued_at < row.bound_at < row.expires_at assert row.token_hash != grant.grant_id + second_request = sha("second prepared request") + second_slot = grant_database.slot.model_copy(update={"slot_index": 1}) + second_attempt = await service(session, BoundaryPorts()).prepare_attempt( + grant_database.execution, + second_slot, + registry_entry_id=grant_database.facts.registry_entry_id, + request_sha256=second_request, + ) + await owner.consume_prepared( + facts, + call_index=1, + request_sha256=second_request, + prepared_attempt_id=second_attempt.attempt_id, + ) + assert await session.scalar( + text( + "select count(*) from checker_model_invocation_grant_calls " + "where grant_id=:grant" + ), + {"grant": row.grant_id}, + ) == 2 + for mutation in ( "update checker_model_invocation_grants set external_request_digest=" "'sha256:" + "d" * 64 + "'", "delete from checker_model_invocation_grants", "truncate checker_model_invocation_grants", + "update checker_model_invocation_grant_calls set request_sha256='sha256:" + + "e" * 64 + + "'", + "delete from checker_model_invocation_grant_calls", + "truncate checker_model_invocation_grant_calls", ): async with AsyncSession(grant_database.facts.engine) as session: with pytest.raises(DBAPIError): @@ -332,7 +380,7 @@ async def test_grant_rejects_reused_or_wrong_authority_and_stale_db_clock( ) -async def test_grant_rejects_locked_binding_and_consumption_substitution( +async def test_grant_rejects_locked_binding_and_call_substitution_or_gaps( grant_database: GrantDatabase, ) -> None: async with AsyncSession(grant_database.facts.engine, expire_on_commit=False) as session: @@ -357,21 +405,35 @@ async def test_grant_rejects_locked_binding_and_consumption_substitution( ) async with AsyncSession(grant_database.facts.engine) as session: - with pytest.raises(DBAPIError): + async with session.begin(): + owner = ModelInvocationGrants( + session, + authority=GrantBoundaries(session), + current=GrantBoundaries(session), + ) + await owner.consume_prepared( + facts, + call_index=0, + request_sha256=grant_database.prepared_request_sha256, + prepared_attempt_id=grant_database.prepared_attempt_id, + ) + + # A call index is an immutable replay key, not permission to replace its request. + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(ModelCallUnavailable): async with session.begin(): - await session.execute( - text( - "update checker_model_invocation_grants set " - "selected_request_sha256=:request,selected_attempt_id=:attempt," - "consumed_at=clock_timestamp()" - ), - { - "request": sha("substituted request"), - "attempt": grant_database.prepared_attempt_id, - }, + await ModelInvocationGrants( + session, + authority=GrantBoundaries(session), + current=GrantBoundaries(session), + ).consume_prepared( + facts, + call_index=0, + request_sha256=sha("substituted request"), + prepared_attempt_id=grant_database.prepared_attempt_id, ) - other_slot = grant_database.slot.model_copy(update={"requirement_id": "other_judge"}) + other_slot = grant_database.slot.model_copy(update={"slot_index": 1}) other_request = sha("other prepared request") async with AsyncSession(grant_database.facts.engine, expire_on_commit=False) as session: async with session.begin(): @@ -384,13 +446,24 @@ async def test_grant_rejects_locked_binding_and_consumption_substitution( async with AsyncSession(grant_database.facts.engine) as session: with pytest.raises(DBAPIError): async with session.begin(): + grant_id = await session.scalar( + text( + "select id from checker_model_invocation_grants " + "where token_hash=:token_hash" + ), + {"token_hash": facts.token_hash}, + ) await session.execute( text( - "update checker_model_invocation_grants set " - "selected_request_sha256=:request,selected_attempt_id=:attempt," - "consumed_at=clock_timestamp()" + "insert into checker_model_invocation_grant_calls" + "(grant_id,call_index,attempt_id,request_sha256) " + "values (:grant,2,:attempt,:request)" ), - {"request": other_request, "attempt": other_attempt.attempt_id}, + { + "grant": grant_id, + "request": other_request, + "attempt": other_attempt.attempt_id, + }, ) foreign_registry = new_record_id() diff --git a/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py new file mode 100644 index 000000000..cf21fb923 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py @@ -0,0 +1,644 @@ +"""Focused setup and shared-budget PostgreSQL model-usage proofs.""" + +from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( + AsyncSession, + BoundaryPorts, + DBAPIError, + DatabaseFacts, + ModelCallBudgetUnavailable, + ModelCallConflict, + ModelCallUsage, + ModelGenerationSettings, + ModelProfileProvisioningFacts, + ModelUsageBudgetAccount, + asyncio, + canonical_json_hash, + evidence, + execution, + model_database as _model_database, + new_record_id, + provision, + pytest, + service, + setup_service, + setup_slot_key, + sha, + slot, + text, + uuid4, +) +from app.modules.model_usage.models import ModelUsageEffect + +model_database = _model_database + + +async def test_provisioning_replay_retains_original_receipts_with_fresh_authority( + model_database: DatabaseFacts, +) -> None: + await provision(model_database, BoundaryPorts(), limit=10_000, replay=True) + + +async def test_profile_publication_rejects_substituted_authority_digest( + model_database: DatabaseFacts, +) -> None: + with pytest.raises(DBAPIError): + await provision( + model_database, + BoundaryPorts(corrupt_admin_receipt=True), + limit=10_000, + ) + + +async def test_profile_publication_is_not_owned_by_a_checker_registry_entry( + model_database: DatabaseFacts, +) -> None: + async with model_database.engine.begin() as connection: + await connection.execute( + text("update external_checker_registry_entries set phase='pre_submit' where id=:id"), + {"id": model_database.registry_entry_id}, + ) + profile = await provision(model_database, BoundaryPorts(), limit=10_000) + assert profile.spec.project_id == model_database.project_id + + +async def test_database_guards_recompute_profile_and_budget_digests( + model_database: DatabaseFacts, +) -> None: + await provision(model_database, BoundaryPorts(), limit=10_000) + async with AsyncSession(model_database.engine) as session: + with pytest.raises(DBAPIError, match="profile digest is invalid"): + async with session.begin(): + await session.execute( + text( + "insert into model_usage_profiles select :id,project_id,2,:digest,provider,api,model_snapshot,generation_settings,maximum_request_bytes,maximum_output_bytes,maximum_response_bytes,maximum_input_tokens,maximum_output_tokens,maximum_calls,maximum_total_request_bytes,maximum_total_response_bytes,maximum_total_input_tokens,maximum_total_output_tokens,allowed_tools,maximum_tool_calls,maximum_tool_output_bytes,deadline_ms,pricing_policy_id,pricing_policy_version,pricing_policy_digest,uncached_input_rate,cached_input_rate,output_rate,:operation,actor_profile_id,:authority,clock_timestamp() from model_usage_profiles where profile_version=1" + ), + { + "id": new_record_id(), + "digest": sha("noncanonical-profile"), + "operation": uuid4(), + "authority": new_record_id(), + }, + ) + + async with AsyncSession(model_database.engine) as session: + with pytest.raises(DBAPIError, match="configuration digest is invalid"): + async with session.begin(): + await session.execute( + text( + "insert into model_usage_budget_configurations select :id,project_id,2,:digest,authorized_total_nanodollars,externally_reconciled_prior_spend_nanodollars,lifetime_limit_nanodollars,:operation,actor_profile_id,:authority,clock_timestamp() from model_usage_budget_configurations where configuration_version=1" + ), + { + "id": new_record_id(), + "digest": sha("noncanonical-budget"), + "operation": uuid4(), + "authority": new_record_id(), + }, + ) + + +async def test_concurrent_dispatch_reserves_one_lifetime_budget( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation) + prepared = [] + for requirement in ("model_a", "model_b"): + run = execution(model_database) + paid_slot = slot(model_database, profile, requirement) + request_sha = sha(requirement) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + item = await service(session, ports).prepare_attempt( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=request_sha, + ) + prepared.append((run, item, evidence(requirement, request_sha))) + + async def dispatch(item): + run, attempt, request_evidence = item + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await service(session, ports).reserve_dispatch( + run, attempt, request_evidence + ) + + results = await asyncio.gather(*(dispatch(item) for item in prepared), return_exceptions=True) + assert sum(not isinstance(result, BaseException) for result in results) == 1 + assert sum(isinstance(result, ModelCallBudgetUnavailable) for result in results) == 1 + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == reservation + assert account.lifetime_settled_nanodollars == 0 + + +async def test_setup_and_checker_dispatches_share_one_account_and_both_win( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + per_dispatch = 100 * 11 + 50 * 29 + profile = await provision( + model_database, + ports, + limit=per_dispatch * 2, + profile_changes={ + "maximum_calls": 2, + "maximum_total_request_bytes": 20_000, + "maximum_total_response_bytes": 60_000, + "maximum_total_input_tokens": 200, + "maximum_total_output_tokens": 100, + }, + ) + checker_run = execution(model_database) + checker_slot = slot(model_database, profile, "shared_budget_checker") + checker_sha = sha("shared-budget-checker") + checker_evidence = evidence("shared-budget-checker", checker_sha) + setup_key = await setup_slot_key(model_database, profile) + setup_sha = sha("shared-budget-setup") + setup_evidence = evidence("shared-budget-setup", setup_sha) + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + checker_prepared = await service(session, ports).prepare_attempt( + checker_run, + checker_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=checker_sha, + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + setup_prepared = await setup_service(session, ports).prepare(setup_key, setup_sha) + + async def reserve_checker(): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await service(session, ports).reserve_dispatch( + checker_run, checker_prepared, checker_evidence + ) + + async def reserve_setup(): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await setup_service(session, ports).reserve_dispatch( + setup_prepared, setup_evidence + ) + + checker_reserved, setup_reserved = await asyncio.gather(reserve_checker(), reserve_setup()) + assert checker_reserved.reserved_nanodollars == per_dispatch + assert setup_reserved.reserved_nanodollars == per_dispatch + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == per_dispatch * 2 + assert account.lifetime_settled_nanodollars == 0 + + +async def test_cheap_completed_call_reserves_remaining_aggregate_before_next_dispatch( + model_database: DatabaseFacts, +) -> None: + """A cheap first result cannot defer an aggregate-token failure until after I/O.""" + ports = BoundaryPorts() + per_dispatch = 100 * 11 + 50 * 29 + profile = await provision( + model_database, + ports, + limit=per_dispatch * 2, + profile_changes={ + "maximum_calls": 2, + "maximum_total_request_bytes": 20_000, + "maximum_total_response_bytes": 60_000, + "maximum_total_input_tokens": 100, + "maximum_total_output_tokens": 100, + }, + ) + run = execution(model_database) + first_slot = slot(model_database, profile, "aggregate_pre_dispatch") + second_slot = first_slot.model_copy(update={"slot_index": 1}) + first_sha = sha("aggregate-pre-dispatch-first") + second_sha = sha("aggregate-pre-dispatch-second") + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + first_prepared = await owner.prepare_attempt( + run, + first_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=first_sha, + ) + first_dispatched = await owner.reserve_dispatch( + run, + first_prepared, + evidence("aggregate-pre-dispatch-first", first_sha), + ) + await owner.complete( + first_dispatched.effect, + provider_response_id="resp_cheap_cached", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=evidence( + "aggregate-pre-dispatch-response", + sha("aggregate-pre-dispatch-response"), + ), + usage=ModelCallUsage( + input_tokens=10, + cached_input_tokens=10, + output_tokens=0, + reasoning_tokens=0, + ), + ) + second_prepared = await owner.prepare_attempt( + run, + second_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=second_sha, + ) + with pytest.raises(ModelCallBudgetUnavailable, match="run_envelope"): + await owner.reserve_dispatch( + run, + second_prepared, + evidence("aggregate-pre-dispatch-second", second_sha), + ) + + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + second = await session.get(ModelUsageEffect, str(second_prepared.attempt_id)) + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == 10 * 2 + assert second is not None and second.state == "prepared" + + +async def test_setup_draft_review_correction_settle_three_shared_calls( + model_database: DatabaseFacts, +) -> None: + """One exact setup attempt can run a finite three-call owner-controlled loop.""" + ports = BoundaryPorts() + per_dispatch = 100 * 11 + 50 * 29 + profile = await provision( + model_database, + ports, + limit=per_dispatch * 3, + profile_changes={ + "maximum_calls": 3, + "maximum_total_request_bytes": 30_000, + "maximum_total_response_bytes": 90_000, + "maximum_total_input_tokens": 300, + "maximum_total_output_tokens": 150, + }, + ) + base = await setup_slot_key(model_database, profile) + settled = 0 + for index, phase in enumerate(("draft", "review", "correction")): + key = base.model_copy(update={"phase": phase, "call_index": index}) + request_sha = sha(f"setup-loop-{phase}-request") + response_sha = sha(f"setup-loop-{phase}-response") + usage = ModelCallUsage( + input_tokens=10 + index, + cached_input_tokens=index, + output_tokens=5 + index, + reasoning_tokens=index, + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = setup_service(session, ports) + prepared = await owner.prepare(key, request_sha) + dispatched = await owner.reserve_dispatch( + prepared, evidence(f"setup-loop-{phase}-request", request_sha) + ) + completed = await owner.complete( + dispatched.effect, + provider_response_id=f"resp_setup_{phase}", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=evidence(f"setup-loop-{phase}-response", response_sha), + usage=usage, + ) + assert completed.effect.slot_key == key + assert completed.state == "completed" + assert completed.replayed is False + settled += completed.settled_nanodollars + + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + states = ( + ( + await session.execute( + text( + "select effect.state from model_usage_effects effect join setup_model_usage_slots slot on slot.id=effect.setup_slot_id where slot.compilation_attempt_id=:attempt order by slot.call_index" + ), + {"attempt": base.compilation_attempt_id}, + ) + ) + .scalars() + .all() + ) + assert states == ["completed", "completed", "completed"] + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == settled + + +async def test_setup_rejects_same_project_profile_substitution_before_effect( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + profile_a = await provision(model_database, ports, limit=1_000_000) + spec_b = profile_a.spec.model_copy( + update={ + "profile_version": 2, + "settings": ModelGenerationSettings( + reasoning_effort="high", + text_verbosity="low", + ), + } + ) + facts_b = ModelProfileProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=spec_b, + profile_digest=canonical_json_hash(spec_b.model_dump(mode="json")), + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + profile_b = await service(session, ports).provision_profile(facts_b) + + locked_key = await setup_slot_key(model_database, profile_a) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await setup_service(session, ports).prepare( + locked_key, + sha("locked-profile-a-request"), + ) + substituted = locked_key.model_copy( + update={ + "profile_id": profile_b.profile_id, + "profile_version": profile_b.spec.profile_version, + "profile_digest": profile_b.profile_digest, + } + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + with pytest.raises(DBAPIError, match="invalid setup model usage envelope ownership"): + async with session.begin(): + await setup_service(session, ports).prepare( + substituted, + sha("same-project-profile-substitution"), + ) + async with AsyncSession(model_database.engine) as session: + counts = ( + await session.execute( + text( + "select count(distinct envelope.id),count(effect.id),min(envelope.profile_id::text) from setup_model_usage_envelopes envelope join setup_model_usage_slots slot on slot.envelope_id=envelope.id join model_usage_effects effect on effect.setup_slot_id=slot.id where envelope.compilation_attempt_id=:attempt" + ), + {"attempt": locked_key.compilation_attempt_id}, + ) + ).one() + assert counts == (1, 1, str(profile_a.profile_id)) + + +async def test_uncertain_setup_effect_replay_never_reauthorizes_dispatch( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation) + key = await setup_slot_key(model_database, profile) + request_sha = sha("setup-uncertain-request") + request_evidence = evidence("setup-uncertain-request", request_sha) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = setup_service(session, ports) + prepared = await owner.prepare(key, request_sha) + dispatched = await owner.reserve_dispatch(prepared, request_evidence) + observed = await owner.mark_uncertain( + dispatched.effect, + sha("setup-uncertain-observation"), + ) + assert observed.state == "uncertain" + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = setup_service(session, ports) + replayed = await owner.prepare(key, request_sha) + with pytest.raises(ModelCallConflict, match="not_dispatchable"): + await owner.reserve_dispatch(replayed, request_evidence) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == reservation + assert account.lifetime_settled_nanodollars == 0 + + +async def test_setup_and_checker_dispatches_share_one_account_cap( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + per_dispatch = 100 * 11 + 50 * 29 + profile = await provision( + model_database, + ports, + limit=per_dispatch, + profile_changes={ + "maximum_calls": 2, + "maximum_total_request_bytes": 20_000, + "maximum_total_response_bytes": 60_000, + "maximum_total_input_tokens": 200, + "maximum_total_output_tokens": 100, + }, + ) + checker_run = execution(model_database) + checker_slot = slot(model_database, profile, "shared_cap_checker") + checker_sha = sha("shared-cap-checker") + checker_evidence = evidence("shared-cap-checker", checker_sha) + setup_key = await setup_slot_key(model_database, profile) + setup_sha = sha("shared-cap-setup") + setup_evidence = evidence("shared-cap-setup", setup_sha) + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + checker_prepared = await service(session, ports).prepare_attempt( + checker_run, + checker_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=checker_sha, + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + setup_prepared = await setup_service(session, ports).prepare(setup_key, setup_sha) + + async def reserve_checker(): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await service(session, ports).reserve_dispatch( + checker_run, checker_prepared, checker_evidence + ) + + async def reserve_setup(): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await setup_service(session, ports).reserve_dispatch( + setup_prepared, setup_evidence + ) + + results = await asyncio.gather(reserve_checker(), reserve_setup(), return_exceptions=True) + assert sum(not isinstance(result, BaseException) for result in results) == 1 + assert sum(isinstance(result, ModelCallBudgetUnavailable) for result in results) == 1 + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == per_dispatch + assert account.lifetime_settled_nanodollars == 0 + + +async def test_setup_reservation_rollback_releases_shared_account( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + per_dispatch = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=per_dispatch) + setup_key = await setup_slot_key(model_database, profile) + setup_sha = sha("rollback-shared-setup") + setup_evidence = evidence("rollback-shared-setup", setup_sha) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + setup_prepared = await setup_service(session, ports).prepare(setup_key, setup_sha) + with pytest.raises(RuntimeError, match="force setup rollback"): + async with session.begin(): + await setup_service(session, ports).reserve_dispatch(setup_prepared, setup_evidence) + raise RuntimeError("force setup rollback") + + checker_run = execution(model_database) + checker_slot = slot(model_database, profile, "after_setup_rollback") + checker_sha = sha("after-setup-rollback") + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + checker_prepared = await service(session, ports).prepare_attempt( + checker_run, + checker_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=checker_sha, + ) + reserved = await service(session, ports).reserve_dispatch( + checker_run, + checker_prepared, + evidence("after-setup-rollback", checker_sha), + ) + assert reserved.reserved_nanodollars == per_dispatch + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == per_dispatch + assert account.lifetime_settled_nanodollars == 0 + + +async def test_fresh_execution_authority_precedes_currentness_and_owner_locks( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation * 2) + run = execution(model_database) + paid_slot = slot(model_database, profile, "model_order") + request_sha = sha("ordered-request") + ports.events.clear() + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + prepared = await owner.prepare_attempt( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=request_sha, + ) + assert ports.events == ["prepare", "current"] + ports.events.clear() + dispatched = await owner.reserve_dispatch( + run, + prepared, + evidence("ordered-request", request_sha), + ) + assert ports.events == ["dispatch", "current"] + await owner.mark_uncertain( + dispatched.effect, + sha("ordered-uncertain"), + provider_response_id="resp_ordered", + ) + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await session.execute( + text("insert into checker_runs(id) values (:id)"), + {"id": run.checker_run_id}, + ) + owner = service(session, ports) + ports.events.clear() + await owner.attach_recovery(run, dispatched.effect) + assert ports.events == ["recover", "current"] + ports.events.clear() + with pytest.raises(ModelCallConflict, match="not_retryable"): + await owner.prepare_recovery_child( + paid_slot, + registry_entry_id=model_database.registry_entry_id, + parent_attempt_id=prepared.attempt_id, + terminal_digest=sha("ordered-uncertain"), + request_sha256=prepared.request_sha256, + ) + assert ports.events == ["child", "recovery_current"] + + +async def test_reservation_rollback_and_completion_settle_exactly_once( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation * 2) + run = execution(model_database) + paid_slot = slot(model_database, profile, "model_rollback") + request_sha = sha("rollback-request") + request_art = evidence("request", request_sha) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + prepared = await service(session, ports).prepare_attempt( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=request_sha, + ) + with pytest.raises(RuntimeError, match="force rollback"): + async with session.begin(): + await service(session, ports).reserve_dispatch(run, prepared, request_art) + raise RuntimeError("force rollback") + async with session.begin(): + dispatched = await service(session, ports).reserve_dispatch(run, prepared, request_art) + + usage = ModelCallUsage( + input_tokens=40, + cached_input_tokens=10, + output_tokens=12, + reasoning_tokens=7, + ) + response_art = evidence("response", sha("response")) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + completed = await owner.complete( + dispatched.effect, + provider_response_id="resp_exact_once", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response_art, + usage=usage, + ) + replay = await owner.complete( + dispatched.effect, + provider_response_id="resp_exact_once", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response_art, + usage=usage, + ) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == completed.settled_nanodollars + assert replay.replayed + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await session.execute( + text("insert into checker_runs(id) values (:id)"), + {"id": run.checker_run_id}, + ) + ports.events.clear() + attached = await service(session, ports).attach_completed(run, dispatched.effect) + assert attached.replayed is False + assert ports.events == ["recover", "current"] diff --git a/backend/tests/checkers_model_calls/test_proxy_orchestration.py b/backend/tests/checkers_model_calls/test_proxy_orchestration.py index 80de4ed7c..8a2ba0e9f 100644 --- a/backend/tests/checkers_model_calls/test_proxy_orchestration.py +++ b/backend/tests/checkers_model_calls/test_proxy_orchestration.py @@ -257,9 +257,10 @@ def proxy(self): async def invoke(self, prompt="Evaluate this work"): return await self.proxy().handle( ModelProxyRequest( - "model_" + uuid4().hex, - canonical_json_hash({"request": 1}), - prompt, + grant_id="model_" + uuid4().hex, + request_digest=canonical_json_hash({"request": 1}), + call_index=0, + prompt=prompt, ) ) @@ -276,8 +277,8 @@ async def test_completed_effect_is_reused_across_proxy_restart(): async def test_invalid_prompt_cannot_consume_grant_store_bytes_or_reserve_spend(): h = Harness() - reply = await h.invoke("") - assert reply.failure_code == "model_request_invalid" + with pytest.raises(ValueError, match="model proxy request is invalid"): + await h.invoke("") assert h.state == "prepared" assert h.request_sha is None assert h.events == [] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 5b0836516..d4100b0b5 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,7 +23,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "54167c45f4c103070342da1361f81cb4d5f03390c9c842dee0738136801637bc" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "72fd1f02469628d883a40fd63aa0890709b29e39185d9c96ce64164aeb5a9412" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", @@ -57,6 +57,7 @@ "authority_idempotency_records", "checker_policies", "checker_model_invocation_grants", + "checker_model_invocation_grant_calls", "model_usage_profiles", "model_usage_budget_accounts", "model_usage_budget_configurations", @@ -187,6 +188,7 @@ "task_post_submit_routing_requests", "checker_policies", "checker_model_invocation_grants", + "checker_model_invocation_grant_calls", "model_usage_profiles", "model_usage_budget_accounts", "model_usage_budget_configurations", diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index 7ab180206..04e7d4bf8 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -69,6 +69,7 @@ def test_checker_delivery_and_routing_preparation_partition_every_node_once() -> "tests/checkers_model_calls/test_model_call_contract.py", "tests/checkers_model_calls/test_model_call_transactions_postgresql.py", "tests/checkers_model_calls/test_model_invocation_grants_postgresql.py", + "tests/checkers_model_calls/test_model_shared_setup_postgresql.py", "tests/checkers_model_calls/test_model_runtime.py", "tests/checkers_model_calls/test_proxy_orchestration.py", "tests/checkers_model_calls/test_public_api.py", @@ -282,8 +283,8 @@ def test_semantic_family_membership_has_an_independent_fingerprint() -> None: "c8a0538c251f77fc1893ed8aa76feff8fa2134cf8e9b5a049f32fbabd5226277", ), "project": ( - 223, - "db9b895b02e5fd38e271a4f8873cdbfb39d78a54a1807885071bd51cef8a715f", + 224, + "af2085eeeeb3f8efce01b9db66ff9d2b6576bf0fb9de9c182a19914fa0507042", ), "task": ( 95, @@ -355,9 +356,9 @@ def test_project_lane_ownership_has_an_independent_fingerprint() -> None: ) + "\n" ) - assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 223 + assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 224 assert hashlib.sha256(canonical.encode()).hexdigest() == ( - "3510a1e0d4ddee134fb03aa258c835b4b06ee75044e06322c8b344ede1a6b7c9" + "aef9cc55c87d95b2ada9ddda594aea06a7aebc7035205710ecb7083256152a25" ) diff --git a/external_checkers/README.md b/external_checkers/README.md index c3f68dd33..3429179c1 100644 --- a/external_checkers/README.md +++ b/external_checkers/README.md @@ -55,23 +55,30 @@ to add a named-project exception or expose the host Docker socket. ## Optional model socket An entitled post-submit invocation may receive one invocation-local Unix socket -at `/work/model.sock`. The image opens one connection and sends exactly -`{"prompt":""}` as UTF-8 JSON, prefixed by a four-byte unsigned -big-endian body length. The JSON body must be non-empty and no larger than 1 MiB. -The image then reads one frame in the same format. Its body is one closed -`workstream.checker_model_proxy.v1` result object: +at `/work/model.sock`. The image opens one connection and sends sequential +closed `workstream.model_session.v2` call objects as UTF-8 JSON, each prefixed +by a four-byte unsigned big-endian body length. A call supplies its exact +zero-based `call_index`, a non-empty `prompt` and a bounded unique +`required_receipt_handles` array. Each frame is at most 1 MiB. The reply echoes +the call index and is one closed session result object: - A completed result has `outcome: "completed"`, a non-empty string `output`, a lowercase `sha256:` `receipt_digest` and a null `failure_code`. - An unavailable result has `outcome: "unavailable"`, null `output` and `receipt_digest`, and a bounded lowercase-identifier `failure_code`. -Both results also contain the exact `protocol_version`; extra or missing fields -are invalid. This image-facing protocol is project-agnostic. The image needs no +Both results also contain the exact `protocol_version` and `session_exhausted`; +extra or missing fields are invalid. The locked model profile bounds calls, +prompt/response bytes, tokens, trusted receipt count/bytes, deadline and the +whole session budget. Receipt handles resolve only inside the current invocation; +the relay injects their ART-verified canonical bytes before spend and hashes +those retained facts rather than the transient handles. This image-facing +protocol is project-agnostic. The image needs no Workstream project code, provider SDK, provider credential, model coordinate or access to the global proxy endpoint. The service wraps the prompt with the -invocation's private grant and request digest. It offers one call slot and keeps -the checker container on `--network none`. +invocation's private grant and request digest. Setup and POST sessions share one +project-lifetime metered account while retaining distinct typed setup-attempt and +checker-invocation lineage. The checker container remains on `--network none`. The dedicated singleton model-proxy worker is optional. Add `model-proxy` to the existing backend and agent-loop Compose profiles to enable it. That profile From 03180a36aa06c98cb0d7fb9efaed84084da26c4f Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 18:52:39 +0100 Subject: [PATCH 011/115] feat(checkers): relay bounded model sessions --- .../src/model_relay.rs | 406 +++++++++++++++--- 1 file changed, 352 insertions(+), 54 deletions(-) diff --git a/external_checkers/workstream-checker-service/src/model_relay.rs b/external_checkers/workstream-checker-service/src/model_relay.rs index 75af693a6..b646df271 100644 --- a/external_checkers/workstream-checker-service/src/model_relay.rs +++ b/external_checkers/workstream-checker-service/src/model_relay.rs @@ -1,7 +1,9 @@ -//! Invocation-local, single-use relay from a checker image to the trusted model proxy. +//! Invocation-local bounded session relay from a checker image to the trusted model proxy. use crate::proxy_transport::{connect_private, read_frame, write_frame}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; use std::fmt; use std::fs::{self, DirBuilder, File}; use std::io::{Read, Write}; @@ -10,13 +12,14 @@ use std::os::unix::fs::{DirBuilderExt, FileTypeExt, MetadataExt, PermissionsExt} use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; pub(crate) const CONTAINER_MODEL_SOCKET: &str = "/work/model.sock"; -const MODEL_PROXY_PROTOCOL: &str = "workstream.checker_model_proxy.v1"; +const MODEL_PROXY_PROTOCOL: &str = "workstream.model_session.v2"; const MODEL_FRAME_MAXIMUM: usize = 1024 * 1024; +const MODEL_MAXIMUM_RECEIPTS: usize = 1000; const INVOCATION_DIRECTORY_PREFIX: &str = ".model-"; pub(crate) use crate::proxy_transport::validate_private_socket as validate_proxy_socket; @@ -61,7 +64,11 @@ where #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ImageModelRequest { + protocol_version: String, + operation: String, + call_index: u32, prompt: String, + required_receipt_handles: Vec, } impl fmt::Debug for ImageModelRequest { @@ -75,7 +82,9 @@ struct ProxyModelRequest<'a> { protocol_version: &'static str, grant_id: &'a str, request_digest: &'a str, + call_index: u32, prompt: &'a str, + resolved_receipts: &'a [ResolvedModelReceipt], } impl fmt::Debug for ProxyModelRequest<'_> { @@ -95,6 +104,141 @@ struct ProxyModelResponse { receipt_digest: Option, #[serde(deserialize_with = "deserialize_required_optional_string")] failure_code: Option, + call_index: u32, + session_exhausted: bool, +} + +#[derive(Clone, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResolvedModelReceipt { + pub(crate) receipt_sha256: String, + pub(crate) canonical_receipt_utf8: String, +} + +impl fmt::Debug for ResolvedModelReceipt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ResolvedModelReceipt()") + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ModelReceiptResolutionFailure { + Invalid, + Unavailable, + LimitExceeded, +} + +pub(crate) trait ModelReceiptResolver: Send + Sync { + fn resolve( + &self, + request_digest: &str, + handles: &[String], + maximum_receipts: usize, + maximum_bytes: usize, + ) -> Result, ModelReceiptResolutionFailure>; +} + +struct RegisteredReceipt { + request_digest: String, + receipt: ResolvedModelReceipt, +} + +#[derive(Default)] +pub(crate) struct InvocationReceiptRegistry { + entries: Mutex>, +} + +impl InvocationReceiptRegistry { + pub(crate) fn register( + &self, + request_digest: &str, + canonical_receipt_utf8: &str, + receipt_sha256: &str, + ) -> Result { + if !valid_sha256(request_digest) + || !valid_sha256(receipt_sha256) + || canonical_receipt_utf8.is_empty() + || canonical_receipt_utf8.len() > MODEL_FRAME_MAXIMUM + || !canonical_json_receipt(canonical_receipt_utf8, receipt_sha256) + { + return Err(ModelReceiptResolutionFailure::Invalid); + } + let mut entries = self + .entries + .lock() + .map_err(|_| ModelReceiptResolutionFailure::Unavailable)?; + for _ in 0..8 { + let mut random = [0_u8; 16]; + File::open("/dev/urandom") + .and_then(|mut source| source.read_exact(&mut random)) + .map_err(|_| ModelReceiptResolutionFailure::Unavailable)?; + let handle = format!( + "trial_receipt_{}", + random + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + if !entries.contains_key(&handle) { + entries.insert( + handle.clone(), + RegisteredReceipt { + request_digest: request_digest.into(), + receipt: ResolvedModelReceipt { + receipt_sha256: receipt_sha256.into(), + canonical_receipt_utf8: canonical_receipt_utf8.into(), + }, + }, + ); + return Ok(handle); + } + } + Err(ModelReceiptResolutionFailure::Unavailable) + } +} + +impl ModelReceiptResolver for InvocationReceiptRegistry { + fn resolve( + &self, + request_digest: &str, + handles: &[String], + maximum_receipts: usize, + maximum_bytes: usize, + ) -> Result, ModelReceiptResolutionFailure> { + if !valid_sha256(request_digest) + || handles.len() > maximum_receipts + || maximum_receipts > MODEL_MAXIMUM_RECEIPTS + || maximum_bytes > MODEL_FRAME_MAXIMUM + { + return Err(ModelReceiptResolutionFailure::LimitExceeded); + } + let unique = handles.iter().collect::>(); + if unique.len() != handles.len() + || handles.iter().any(|handle| !valid_receipt_handle(handle)) + { + return Err(ModelReceiptResolutionFailure::Invalid); + } + let entries = self + .entries + .lock() + .map_err(|_| ModelReceiptResolutionFailure::Unavailable)?; + let mut total = 0_usize; + let mut receipts = Vec::with_capacity(handles.len()); + for handle in handles { + let entry = entries + .get(handle) + .filter(|entry| entry.request_digest == request_digest) + .ok_or(ModelReceiptResolutionFailure::Unavailable)?; + total = total + .checked_add(entry.receipt.canonical_receipt_utf8.len()) + .ok_or(ModelReceiptResolutionFailure::LimitExceeded)?; + if total > maximum_bytes { + return Err(ModelReceiptResolutionFailure::LimitExceeded); + } + receipts.push(entry.receipt.clone()); + } + Ok(receipts) + } } impl fmt::Debug for ProxyModelResponse { @@ -156,6 +300,29 @@ impl InvocationRelay { sandbox_uid: u32, sandbox_gid: u32, deadline: Instant, + ) -> Result { + Self::start_with_resolver( + relay_root, + proxy_path, + grant, + request_digest, + Arc::new(InvocationReceiptRegistry::default()), + sandbox_uid, + sandbox_gid, + deadline, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn start_with_resolver( + relay_root: &Path, + proxy_path: &Path, + grant: &ModelGrant, + request_digest: &str, + receipt_resolver: Arc, + sandbox_uid: u32, + sandbox_gid: u32, + deadline: Instant, ) -> Result { validate_proxy_socket(proxy_path).map_err(|_| RelayStartFailure::ordinary())?; validate_relay_root(relay_root).map_err(|_| RelayStartFailure::ordinary())?; @@ -227,6 +394,7 @@ impl InvocationRelay { &proxy, &grant, &digest, + receipt_resolver.as_ref(), deadline, &worker_cancelled, ) @@ -353,6 +521,7 @@ fn relay_once( proxy_path: &Path, grant: &ModelGrant, request_digest: &str, + receipt_resolver: &dyn ModelReceiptResolver, deadline: Instant, cancelled: &AtomicBool, ) -> Result<(), String> { @@ -371,48 +540,81 @@ fn relay_once( Err(_) => return Err("model relay accept failed".into()), } }; - drop(listener); // one call slot: no second connection can be accepted. + drop(listener); // one bounded session: no second connection can be accepted. image .set_nonblocking(true) .map_err(|_| "model relay image socket invalid")?; - let request = read_frame(&mut image, MODEL_FRAME_MAXIMUM, deadline, cancelled)?; - let image_request: ImageModelRequest = - serde_json::from_slice(&request).map_err(|_| "model relay request invalid")?; - let wrapped = serde_json::to_vec(&ProxyModelRequest { - protocol_version: MODEL_PROXY_PROTOCOL, - grant_id: &grant.grant_id, - request_digest, - prompt: &image_request.prompt, - }) - .map_err(|_| "model relay request invalid")?; - if wrapped.is_empty() || wrapped.len() > MODEL_FRAME_MAXIMUM { - return Err("model relay request unbounded".into()); - } - let mut proxy = connect_private(proxy_path, deadline, cancelled)?; - write_frame( - &mut proxy, - &wrapped, - MODEL_FRAME_MAXIMUM, - deadline, - cancelled, - )?; - let response = read_frame(&mut proxy, MODEL_FRAME_MAXIMUM, deadline, cancelled)?; - let response_value: ProxyModelResponse = - serde_json::from_slice(&response).map_err(|_| "model relay response invalid")?; - if !valid_proxy_response(&response_value) { - return Err("model relay response invalid".into()); - } - write_frame( - &mut image, - &response, - MODEL_FRAME_MAXIMUM, - deadline, - cancelled, - ) + let mut next_call_index = 0_u32; + let mut completed_calls = 0_u32; + loop { + let request = match read_frame(&mut image, MODEL_FRAME_MAXIMUM, deadline, cancelled) { + Ok(request) => request, + Err(_) if cancelled.load(Ordering::Acquire) && completed_calls > 0 => return Ok(()), + Err(error) => return Err(error), + }; + let image_request: ImageModelRequest = + serde_json::from_slice(&request).map_err(|_| "model relay request invalid")?; + if image_request.protocol_version != MODEL_PROXY_PROTOCOL + || image_request.operation != "call" + || image_request.call_index != next_call_index + || image_request.call_index > 99 + || image_request.prompt.is_empty() + { + return Err("model relay request invalid".into()); + } + let resolved_receipts = receipt_resolver + .resolve( + request_digest, + &image_request.required_receipt_handles, + MODEL_MAXIMUM_RECEIPTS, + MODEL_FRAME_MAXIMUM, + ) + .map_err(|_| "model relay receipt unavailable")?; + let wrapped = serde_json::to_vec(&ProxyModelRequest { + protocol_version: MODEL_PROXY_PROTOCOL, + grant_id: &grant.grant_id, + request_digest, + call_index: image_request.call_index, + prompt: &image_request.prompt, + resolved_receipts: &resolved_receipts, + }) + .map_err(|_| "model relay request invalid")?; + if wrapped.is_empty() || wrapped.len() > MODEL_FRAME_MAXIMUM { + return Err("model relay request unbounded".into()); + } + let mut proxy = connect_private(proxy_path, deadline, cancelled)?; + write_frame( + &mut proxy, + &wrapped, + MODEL_FRAME_MAXIMUM, + deadline, + cancelled, + )?; + let response = read_frame(&mut proxy, MODEL_FRAME_MAXIMUM, deadline, cancelled)?; + let response_value: ProxyModelResponse = + serde_json::from_slice(&response).map_err(|_| "model relay response invalid")?; + if !valid_proxy_response(&response_value, image_request.call_index) { + return Err("model relay response invalid".into()); + } + write_frame( + &mut image, + &response, + MODEL_FRAME_MAXIMUM, + deadline, + cancelled, + )?; + completed_calls += 1; + if response_value.session_exhausted || response_value.outcome == "unavailable" { + return Ok(()); + } + next_call_index = next_call_index + .checked_add(1) + .ok_or_else(|| "model relay session exhausted".to_string())?; + } } -fn valid_proxy_response(response: &ProxyModelResponse) -> bool { - if response.protocol_version != MODEL_PROXY_PROTOCOL { +fn valid_proxy_response(response: &ProxyModelResponse, call_index: u32) -> bool { + if response.protocol_version != MODEL_PROXY_PROTOCOL || response.call_index != call_index { return false; } match response.outcome.as_str() { @@ -445,11 +647,47 @@ fn valid_sha256(value: &str) -> bool { } fn valid_failure_code(value: &str) -> bool { - (1..=64).contains(&value.len()) - && value.as_bytes()[0].is_ascii_lowercase() - && value + matches!( + value, + "model_request_invalid" + | "model_request_too_large" + | "model_receipt_unavailable" + | "model_tool_limit_exceeded" + | "model_session_exhausted" + | "model_session_deadline" + | "model_budget_unavailable" + | "model_call_unavailable" + | "model_call_operator_recovery_required" + | "model_provider_no_effect" + | "model_provider_delivery_uncertain" + | "model_provider_pending" + | "model_provider_recovery_unavailable" + | "model_provider_usage_unavailable" + | "model_provider_failed" + | "model_response_too_large" + | "model_transport_unavailable" + ) +} + +fn valid_receipt_handle(value: &str) -> bool { + value.len() == 46 + && value.starts_with("trial_receipt_") + && value[14..] .bytes() - .all(|item| item.is_ascii_lowercase() || item.is_ascii_digit() || item == b'_') + .all(|item| item.is_ascii_hexdigit() && !item.is_ascii_uppercase()) +} + +fn canonical_json_receipt(value: &str, expected_sha256: &str) -> bool { + let parsed: serde_json::Value = match serde_json::from_str(value) { + Ok(parsed) => parsed, + Err(_) => return false, + }; + if !matches!(serde_json::to_string(&parsed), Ok(encoded) if encoded == value) { + return false; + } + let mut digest = Sha256::new(); + digest.update(value.as_bytes()); + format!("sha256:{:x}", digest.finalize()) == expected_sha256 } fn cleanup_paths(paths: &RelayPaths) -> bool { @@ -559,6 +797,41 @@ mod tests { } } + #[test] + fn receipt_registry_is_request_bound_canonical_unique_and_bounded() { + let registry = InvocationReceiptRegistry::default(); + let request = format!("sha256:{}", "a".repeat(64)); + let foreign = format!("sha256:{}", "b".repeat(64)); + let canonical = r#"{"facts":{"exit_code":0},"schema":"trial.v1"}"#; + let mut digest = Sha256::new(); + digest.update(canonical.as_bytes()); + let receipt_sha = format!("sha256:{:x}", digest.finalize()); + let handle = registry + .register(&request, canonical, &receipt_sha) + .unwrap(); + assert!(valid_receipt_handle(&handle)); + assert_eq!( + registry.resolve(&request, std::slice::from_ref(&handle), 1, canonical.len()), + Ok(vec![ResolvedModelReceipt { + receipt_sha256: receipt_sha.clone(), + canonical_receipt_utf8: canonical.into(), + }]) + ); + assert_eq!( + registry.resolve(&foreign, std::slice::from_ref(&handle), 1, canonical.len()), + Err(ModelReceiptResolutionFailure::Unavailable) + ); + assert_eq!( + registry.resolve(&request, &[handle.clone(), handle], 2, canonical.len() * 2), + Err(ModelReceiptResolutionFailure::Invalid) + ); + assert_eq!(registry.resolve(&request, &[], 0, 0), Ok(Vec::new())); + assert_eq!( + registry.register(&request, "{\"z\":0,\"a\":1}", &receipt_sha), + Err(ModelReceiptResolutionFailure::Invalid) + ); + } + #[test] fn diagnostic_output_redacts_model_grants_prompts_and_results() { let grant_id = format!("model_{}", "1".repeat(32)); @@ -571,13 +844,19 @@ mod tests { }; let required = RequiredModelGrant(Some(grant.clone())); let image = ImageModelRequest { + protocol_version: MODEL_PROXY_PROTOCOL.into(), + operation: "call".into(), + call_index: 0, prompt: prompt.into(), + required_receipt_handles: vec![], }; let request = ProxyModelRequest { protocol_version: MODEL_PROXY_PROTOCOL, grant_id: &grant.grant_id, request_digest: &digest, + call_index: 0, prompt, + resolved_receipts: &[], }; let response = ProxyModelResponse { protocol_version: MODEL_PROXY_PROTOCOL.into(), @@ -585,6 +864,8 @@ mod tests { output: Some(output.into()), receipt_digest: Some(format!("sha256:{}", "b".repeat(64))), failure_code: None, + call_index: 0, + session_exhausted: true, }; for diagnostic in [ @@ -613,16 +894,20 @@ mod tests { output: Some("answer".into()), receipt_digest: Some(format!("sha256:{}", "a".repeat(64))), failure_code: None, + call_index: 0, + session_exhausted: true, }; - assert!(valid_proxy_response(&completed)); + assert!(valid_proxy_response(&completed, 0)); let unavailable = ProxyModelResponse { protocol_version: MODEL_PROXY_PROTOCOL.into(), outcome: "unavailable".into(), output: None, receipt_digest: None, - failure_code: Some("budget_unavailable".into()), + failure_code: Some("model_budget_unavailable".into()), + call_index: 0, + session_exhausted: true, }; - assert!(valid_proxy_response(&unavailable)); + assert!(valid_proxy_response(&unavailable, 0)); for invalid in [ ProxyModelResponse { failure_code: Some("provider_error: secret".into()), @@ -633,20 +918,20 @@ mod tests { ..completed.clone() }, ] { - assert!(!valid_proxy_response(&invalid)); + assert!(!valid_proxy_response(&invalid, 0)); } assert!(serde_json::from_slice::( - br#"{"protocol_version":"workstream.checker_model_proxy.v1","outcome":"unavailable","output":null,"receipt_digest":null,"failure_code":"provider_unavailable","provider_id":"secret"}"# + br#"{"protocol_version":"workstream.model_session.v2","outcome":"unavailable","output":null,"receipt_digest":null,"failure_code":"model_provider_failed","call_index":0,"session_exhausted":true,"provider_id":"secret"}"# ) .is_err()); assert!(serde_json::from_slice::( - br#"{"protocol_version":"workstream.checker_model_proxy.v1","outcome":"unavailable","output":null,"failure_code":"provider_unavailable"}"# + br#"{"protocol_version":"workstream.model_session.v2","outcome":"unavailable","output":null,"failure_code":"model_provider_failed","call_index":0,"session_exhausted":true}"# ) .is_err()); } #[test] - fn relay_wraps_one_exchange_and_cleans_invocation_socket() { + fn relay_wraps_bounded_sequential_calls_and_cleans_invocation_socket() { let root = private_root(); let proxy_path = root.join("proxy.sock"); let proxy = UnixListener::bind(&proxy_path).unwrap(); @@ -670,19 +955,32 @@ mod tests { .unwrap(); let relay_path = relay.socket_path().to_owned(); let mut image = UnixStream::connect(&relay_path).unwrap(); - frame(&mut image, br#"{"prompt":"review this"}"#); + frame(&mut image, br#"{"protocol_version":"workstream.model_session.v2","operation":"call","call_index":0,"prompt":"review this","required_receipt_handles":[]}"#); let (mut backend, _) = proxy.accept().unwrap(); let wrapped: serde_json::Value = serde_json::from_slice(&receive(&mut backend)).unwrap(); assert_eq!(wrapped["protocol_version"], MODEL_PROXY_PROTOCOL); assert_eq!(wrapped["grant_id"], grant.grant_id); assert_eq!(wrapped["request_digest"], digest); + assert_eq!(wrapped["call_index"], 0); assert_eq!(wrapped["prompt"], "review this"); + assert_eq!(wrapped["resolved_receipts"], serde_json::json!([])); let response = format!( - r#"{{"protocol_version":"{MODEL_PROXY_PROTOCOL}","outcome":"completed","output":"bounded","receipt_digest":"sha256:{}","failure_code":null}}"#, + r#"{{"protocol_version":"{MODEL_PROXY_PROTOCOL}","outcome":"completed","output":"bounded","receipt_digest":"sha256:{}","failure_code":null,"call_index":0,"session_exhausted":false}}"#, "b".repeat(64) ); frame(&mut backend, response.as_bytes()); assert_eq!(receive(&mut image), response.as_bytes()); + frame(&mut image, br#"{"protocol_version":"workstream.model_session.v2","operation":"call","call_index":1,"prompt":"assess this","required_receipt_handles":[]}"#); + let (mut backend, _) = proxy.accept().unwrap(); + let wrapped: serde_json::Value = serde_json::from_slice(&receive(&mut backend)).unwrap(); + assert_eq!(wrapped["call_index"], 1); + assert_eq!(wrapped["prompt"], "assess this"); + let response = format!( + r#"{{"protocol_version":"{MODEL_PROXY_PROTOCOL}","outcome":"completed","output":"complete","receipt_digest":"sha256:{}","failure_code":null,"call_index":1,"session_exhausted":true}}"#, + "c".repeat(64) + ); + frame(&mut backend, response.as_bytes()); + assert_eq!(receive(&mut image), response.as_bytes()); assert!(UnixStream::connect(&relay_path).is_err()); assert_eq!( relay.finish(), @@ -805,7 +1103,7 @@ mod tests { ) .unwrap(); let mut image = UnixStream::connect(relay.socket_path()).unwrap(); - frame(&mut image, br#"{"prompt":"review this"}"#); + frame(&mut image, br#"{"protocol_version":"workstream.model_session.v2","operation":"call","call_index":0,"prompt":"review this","required_receipt_handles":[]}"#); let (mut backend, _) = proxy.accept().unwrap(); let _ = receive(&mut backend); frame(&mut backend, br#"["not","a","contract"]"#); From 30ef4df10506f59d589f10fcc130a74a76c72686 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 19:05:20 +0100 Subject: [PATCH 012/115] chore(mcp): refresh model usage catalogue snapshots --- mcp_server/contracts/admin_roles_list.json | 6 +- .../contracts/authorization_context_get.json | 318 +++++++------- mcp_server/contracts/permissions_list.json | 8 +- mcp_server/contracts/profile_get.json | 340 +++++++-------- mcp_server/contracts/profile_update.json | 398 +++++++++--------- 5 files changed, 541 insertions(+), 529 deletions(-) diff --git a/mcp_server/contracts/admin_roles_list.json b/mcp_server/contracts/admin_roles_list.json index b52840f32..18dde776b 100644 --- a/mcp_server/contracts/admin_roles_list.json +++ b/mcp_server/contracts/admin_roles_list.json @@ -1,5 +1,5 @@ { - "canonical_sha256": "c202da7ee0b8bbbeddf16471fcd0e82e29c75464bb65c8387780f5d9ff9bab7c", + "canonical_sha256": "e73bd0a8756d4d39a68cb132ab491691f160115a237b17d097efb7dfe877c2c4", "components": { "schemas": { "AdminRole": { @@ -162,6 +162,10 @@ "checker.model_call.observe", "checker.model_call.complete", "checker.model_call.grant", + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.observe", + "project.guide_compilation.model.complete", "project.model_profile.manage", "project.model_budget.manage", "project.model_call.recovery_child.authorize", diff --git a/mcp_server/contracts/authorization_context_get.json b/mcp_server/contracts/authorization_context_get.json index 87ef52804..7dd28e714 100644 --- a/mcp_server/contracts/authorization_context_get.json +++ b/mcp_server/contracts/authorization_context_get.json @@ -1,5 +1,5 @@ { - "canonical_sha256": "49952b484c5a99b013f492d027744844e3c18cd91d2bf68ff8db07fad58e7f47", + "canonical_sha256": "f6bf204e2a075373d1ac737da6ce882057c2da09b89cdc41b5db8d05ac269a72", "components": { "schemas": { "ActionId": { @@ -65,6 +65,10 @@ "checker.model_call.observe", "checker.model_call.complete", "checker.model_call.grant", + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.observe", + "project.guide_compilation.model.complete", "project.model_profile.provision", "project.model_budget.configure", "project.model_call.recovery_child.authorize", @@ -196,54 +200,54 @@ "type": "string" }, "ActorAuthorizationContextResponse": { + "additionalProperties": false, + "description": "Self-only effective authority projected onto one canonical project.", "properties": { "actor_profile_id": { - "type": "string", "format": "uuid", - "title": "Actor Profile Id" - }, - "status": { - "type": "string", - "enum": [ - "active", - "suspended", - "deactivated" - ], - "title": "Status" - }, - "project_id": { - "type": "string", - "format": "uuid", - "title": "Project Id" + "title": "Actor Profile Id", + "type": "string" }, "admin_roles": { "items": { "type": "string" }, - "type": "array", - "title": "Admin Roles" + "title": "Admin Roles", + "type": "array" + }, + "effective_action_ids": { + "items": { + "$ref": "#/components/schemas/ActionId" + }, + "title": "Effective Action Ids", + "type": "array" + }, + "project_id": { + "format": "uuid", + "title": "Project Id", + "type": "string" }, "project_roles": { "items": { - "type": "string", "enum": [ "submitter", "reviewer" - ] + ], + "type": "string" }, - "type": "array", - "title": "Project Roles" + "title": "Project Roles", + "type": "array" }, - "effective_action_ids": { - "items": { - "$ref": "#/components/schemas/ActionId" - }, - "type": "array", - "title": "Effective Action Ids" + "status": { + "enum": [ + "active", + "suspended", + "deactivated" + ], + "title": "Status", + "type": "string" } }, - "additionalProperties": false, - "type": "object", "required": [ "actor_profile_id", "status", @@ -253,34 +257,34 @@ "effective_action_ids" ], "title": "ActorAuthorizationContextResponse", - "description": "Self-only effective authority projected onto one canonical project." + "type": "object" }, "ApiError": { + "additionalProperties": false, + "description": "Canonical machine-readable API error.", "properties": { "code": { - "type": "string", - "title": "Code" + "title": "Code", + "type": "string" }, - "message": { - "type": "string", - "title": "Message" + "correlation_id": { + "title": "Correlation Id", + "type": "string" }, "details": { "additionalProperties": true, - "type": "object", - "title": "Details" + "title": "Details", + "type": "object" }, - "correlation_id": { - "type": "string", - "title": "Correlation Id" + "message": { + "title": "Message", + "type": "string" }, "retryable": { - "type": "boolean", - "title": "Retryable" + "title": "Retryable", + "type": "boolean" } }, - "additionalProperties": false, - "type": "object", "required": [ "code", "message", @@ -289,13 +293,12 @@ "retryable" ], "title": "ApiError", - "description": "Canonical machine-readable API error." + "type": "object" }, "ApiErrorResponse": { + "additionalProperties": false, + "description": "Canonical error plus additive legacy compatibility fields.", "properties": { - "error": { - "$ref": "#/components/schemas/ApiError" - }, "detail": { "anyOf": [ {}, @@ -304,88 +307,79 @@ } ], "title": "Detail" + }, + "error": { + "$ref": "#/components/schemas/ApiError" } }, - "additionalProperties": false, - "type": "object", "required": [ "error" ], "title": "ApiErrorResponse", - "description": "Canonical error plus additive legacy compatibility fields." + "type": "object" }, "HTTPValidationError": { - "type": "object", - "required": [ - "detail", - "error" - ], + "additionalProperties": false, "properties": { "detail": { - "type": "array", "items": { "type": "object" - } + }, + "type": "array" }, "error": { "$ref": "#/components/schemas/ApiError" } }, - "additionalProperties": false + "required": [ + "detail", + "error" + ], + "type": "object" } } }, "operation": { - "tags": [ - "actors" - ], - "summary": "Read Current Actor Authorization Context", "description": "Return the caller's effective authority for one canonical project.", "operationId": "read_current_actor_authorization_context_api_v1_actors_me_authorization_context_get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { - "name": "project_id", "in": "query", + "name": "project_id", "required": true, "schema": { - "type": "string", - "minLength": 1, "maxLength": 100, + "minLength": 1, "pattern": "^[^\\x00]*$", - "title": "Project Id" + "title": "Project Id", + "type": "string" } }, { - "name": "X-Request-ID", "in": "header", + "name": "X-Request-ID", "required": false, "schema": { - "type": "string", "format": "uuid", + "maxLength": 36, "minLength": 36, - "maxLength": 36 + "type": "string" } }, { - "name": "X-Correlation-ID", "in": "header", + "name": "X-Correlation-ID", "required": false, "schema": { - "type": "string", "format": "uuid", + "maxLength": 36, "minLength": 36, - "maxLength": 36 + "type": "string" } } ], "responses": { "200": { - "description": "Successful Response", "content": { "application/json": { "schema": { @@ -393,185 +387,195 @@ } } }, + "description": "Successful Response", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } } }, "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + }, "description": "Invalid request.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } - }, + } + }, + "401": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "500": { - "description": "Internal server error.", + }, + "description": "Authentication failed.", "headers": { - "X-Request-ID": { - "required": true, + "WWW-Authenticate": { "schema": { - "type": "string", - "format": "uuid" + "type": "string" } }, "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } - } - }, - "content": { - "application/json": { + }, + "X-Request-ID": { + "required": true, "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" + "format": "uuid", + "type": "string" } } } }, - "422": { - "description": "Validation Error", + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HTTPValidationError" + "$ref": "#/components/schemas/ApiErrorResponse" } } }, + "description": "Permission denied.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } } }, - "401": { - "description": "Authentication failed.", - "headers": { - "X-Request-ID": { - "required": true, + "422": { + "content": { + "application/json": { "schema": { - "type": "string", - "format": "uuid" + "$ref": "#/components/schemas/HTTPValidationError" } - }, + } + }, + "description": "Validation Error", + "headers": { "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "WWW-Authenticate": { + "X-Request-ID": { + "required": true, "schema": { + "format": "uuid", "type": "string" } } - }, + } + }, + "500": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "403": { - "description": "Permission denied.", + }, + "description": "Internal server error.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } - }, + } + }, + "503": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "503": { + }, "description": "Identity verification unavailable.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" + "format": "uuid", + "type": "string" } } } } }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Read Current Actor Authorization Context", + "tags": [ + "actors" + ], "x-workstream-action-id": "actor.authorization_context.read" }, "schema_version": 1, diff --git a/mcp_server/contracts/permissions_list.json b/mcp_server/contracts/permissions_list.json index 54497e46c..bfe560844 100644 --- a/mcp_server/contracts/permissions_list.json +++ b/mcp_server/contracts/permissions_list.json @@ -1,5 +1,5 @@ { - "canonical_sha256": "4ebd90f7658f1d67b402a4818d718d71cb4b3ab68042466a5844b345869de20c", + "canonical_sha256": "a72e8ae2d0139701a2cb3a2be6e3b0a9888c023e2ff21fbcbb97498e0db8a366", "components": { "schemas": { "ApiError": { @@ -87,7 +87,7 @@ "type": "array" }, "total": { - "const": 91, + "const": 95, "title": "Total", "type": "integer" } @@ -125,6 +125,10 @@ "checker.model_call.observe", "checker.model_call.complete", "checker.model_call.grant", + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.observe", + "project.guide_compilation.model.complete", "project.model_profile.manage", "project.model_budget.manage", "project.model_call.recovery_child.authorize", diff --git a/mcp_server/contracts/profile_get.json b/mcp_server/contracts/profile_get.json index f71a40db4..d5a089ce5 100644 --- a/mcp_server/contracts/profile_get.json +++ b/mcp_server/contracts/profile_get.json @@ -3,53 +3,27 @@ "components": { "schemas": { "ActorProfileSelfResponse": { + "additionalProperties": false, + "description": "Privacy-bounded canonical profile returned to its human owner.", "properties": { - "actor_profile_id": { - "type": "string", - "title": "Actor Profile Id" - }, "actor_kind": { - "type": "string", "const": "human", - "title": "Actor Kind" - }, - "status": { - "type": "string", - "enum": [ - "active", - "suspended", - "deactivated" - ], - "title": "Status" + "title": "Actor Kind", + "type": "string" }, - "domains": { - "items": { - "type": "string", - "const": "contributor" - }, - "type": "array", - "title": "Domains", - "default": [ - "contributor" - ] + "actor_profile_id": { + "title": "Actor Profile Id", + "type": "string" }, "admin_roles": { + "default": [], "items": { "type": "string" }, - "type": "array", "title": "Admin Roles", - "default": [] - }, - "project_role_grants": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Project Role Grants", - "default": [] + "type": "array" }, - "display_name": { + "contact_email": { "anyOf": [ { "type": "string" @@ -58,9 +32,14 @@ "type": "null" } ], - "title": "Display Name" + "title": "Contact Email" }, - "contact_email": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "display_name": { "anyOf": [ { "type": "string" @@ -69,33 +48,54 @@ "type": "null" } ], - "title": "Contact Email" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" + "title": "Display Name" }, - "updated_at": { - "type": "string", - "format": "date-time", - "title": "Updated At" + "domains": { + "default": [ + "contributor" + ], + "items": { + "const": "contributor", + "type": "string" + }, + "title": "Domains", + "type": "array" }, "last_seen_at": { "anyOf": [ { - "type": "string", - "format": "date-time" + "format": "date-time", + "type": "string" }, { "type": "null" } ], "title": "Last Seen At" + }, + "project_role_grants": { + "default": [], + "items": { + "type": "string" + }, + "title": "Project Role Grants", + "type": "array" + }, + "status": { + "enum": [ + "active", + "suspended", + "deactivated" + ], + "title": "Status", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" } }, - "additionalProperties": false, - "type": "object", "required": [ "actor_profile_id", "actor_kind", @@ -107,34 +107,34 @@ "last_seen_at" ], "title": "ActorProfileSelfResponse", - "description": "Privacy-bounded canonical profile returned to its human owner." + "type": "object" }, "ApiError": { + "additionalProperties": false, + "description": "Canonical machine-readable API error.", "properties": { "code": { - "type": "string", - "title": "Code" + "title": "Code", + "type": "string" }, - "message": { - "type": "string", - "title": "Message" + "correlation_id": { + "title": "Correlation Id", + "type": "string" }, "details": { "additionalProperties": true, - "type": "object", - "title": "Details" + "title": "Details", + "type": "object" }, - "correlation_id": { - "type": "string", - "title": "Correlation Id" + "message": { + "title": "Message", + "type": "string" }, "retryable": { - "type": "boolean", - "title": "Retryable" + "title": "Retryable", + "type": "boolean" } }, - "additionalProperties": false, - "type": "object", "required": [ "code", "message", @@ -143,13 +143,12 @@ "retryable" ], "title": "ApiError", - "description": "Canonical machine-readable API error." + "type": "object" }, "ApiErrorResponse": { + "additionalProperties": false, + "description": "Canonical error plus additive legacy compatibility fields.", "properties": { - "error": { - "$ref": "#/components/schemas/ApiError" - }, "detail": { "anyOf": [ {}, @@ -158,28 +157,48 @@ } ], "title": "Detail" + }, + "error": { + "$ref": "#/components/schemas/ApiError" } }, - "additionalProperties": false, - "type": "object", "required": [ "error" ], "title": "ApiErrorResponse", - "description": "Canonical error plus additive legacy compatibility fields." + "type": "object" } } }, "operation": { - "tags": [ - "actors" - ], - "summary": "Read Current Actor Profile", "description": "Return the caller's canonical Contributor-domain profile.", "operationId": "read_current_actor_profile_api_v1_actors_me_get", + "parameters": [ + { + "in": "header", + "name": "X-Request-ID", + "required": false, + "schema": { + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "type": "string" + } + }, + { + "in": "header", + "name": "X-Correlation-ID", + "required": false, + "schema": { + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "type": "string" + } + } + ], "responses": { "200": { - "description": "Successful Response", "content": { "application/json": { "schema": { @@ -187,156 +206,157 @@ } } }, + "description": "Successful Response", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } } }, "400": { - "description": "Invalid request.", - "headers": { - "X-Request-ID": { - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - "X-Correlation-ID": { - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "500": { - "description": "Internal server error.", + }, + "description": "Invalid request.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } - }, + } + }, + "401": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "401": { + }, "description": "Authentication failed.", "headers": { - "X-Request-ID": { - "required": true, + "WWW-Authenticate": { "schema": { - "type": "string", - "format": "uuid" + "type": "string" } }, "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "WWW-Authenticate": { + "X-Request-ID": { + "required": true, "schema": { + "format": "uuid", "type": "string" } } - }, + } + }, + "403": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "403": { + }, "description": "Permission denied.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } - }, + } + }, + "500": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "503": { - "description": "Identity verification unavailable.", + }, + "description": "Internal server error.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } - }, + } + }, + "503": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } + }, + "description": "Identity verification unavailable.", + "headers": { + "X-Correlation-ID": { + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + "X-Request-ID": { + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } } } }, @@ -345,31 +365,11 @@ "HTTPBearer": [] } ], - "x-workstream-action-id": "actor.profile.read_self", - "parameters": [ - { - "name": "X-Request-ID", - "in": "header", - "required": false, - "schema": { - "type": "string", - "format": "uuid", - "minLength": 36, - "maxLength": 36 - } - }, - { - "name": "X-Correlation-ID", - "in": "header", - "required": false, - "schema": { - "type": "string", - "format": "uuid", - "minLength": 36, - "maxLength": 36 - } - } - ] + "summary": "Read Current Actor Profile", + "tags": [ + "actors" + ], + "x-workstream-action-id": "actor.profile.read_self" }, "schema_version": 1, "source": { diff --git a/mcp_server/contracts/profile_update.json b/mcp_server/contracts/profile_update.json index c8d18e86e..740be1c6d 100644 --- a/mcp_server/contracts/profile_update.json +++ b/mcp_server/contracts/profile_update.json @@ -3,53 +3,27 @@ "components": { "schemas": { "ActorProfileSelfResponse": { + "additionalProperties": false, + "description": "Privacy-bounded canonical profile returned to its human owner.", "properties": { - "actor_profile_id": { - "type": "string", - "title": "Actor Profile Id" - }, "actor_kind": { - "type": "string", "const": "human", - "title": "Actor Kind" + "title": "Actor Kind", + "type": "string" }, - "status": { - "type": "string", - "enum": [ - "active", - "suspended", - "deactivated" - ], - "title": "Status" - }, - "domains": { - "items": { - "type": "string", - "const": "contributor" - }, - "type": "array", - "title": "Domains", - "default": [ - "contributor" - ] + "actor_profile_id": { + "title": "Actor Profile Id", + "type": "string" }, "admin_roles": { + "default": [], "items": { "type": "string" }, - "type": "array", "title": "Admin Roles", - "default": [] + "type": "array" }, - "project_role_grants": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Project Role Grants", - "default": [] - }, - "display_name": { + "contact_email": { "anyOf": [ { "type": "string" @@ -58,9 +32,14 @@ "type": "null" } ], - "title": "Display Name" + "title": "Contact Email" }, - "contact_email": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "display_name": { "anyOf": [ { "type": "string" @@ -69,33 +48,54 @@ "type": "null" } ], - "title": "Contact Email" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" + "title": "Display Name" }, - "updated_at": { - "type": "string", - "format": "date-time", - "title": "Updated At" + "domains": { + "default": [ + "contributor" + ], + "items": { + "const": "contributor", + "type": "string" + }, + "title": "Domains", + "type": "array" }, "last_seen_at": { "anyOf": [ { - "type": "string", - "format": "date-time" + "format": "date-time", + "type": "string" }, { "type": "null" } ], "title": "Last Seen At" + }, + "project_role_grants": { + "default": [], + "items": { + "type": "string" + }, + "title": "Project Role Grants", + "type": "array" + }, + "status": { + "enum": [ + "active", + "suspended", + "deactivated" + ], + "title": "Status", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" } }, - "additionalProperties": false, - "type": "object", "required": [ "actor_profile_id", "actor_kind", @@ -107,66 +107,66 @@ "last_seen_at" ], "title": "ActorProfileSelfResponse", - "description": "Privacy-bounded canonical profile returned to its human owner." + "type": "object" }, "ActorProfileUpdateRequest": { + "additionalProperties": false, + "description": "Human-owned display fields accepted by the canonical self API.", "properties": { - "display_name": { + "contact_email": { "anyOf": [ { - "type": "string", - "maxLength": 200 + "maxLength": 320, + "type": "string" }, { "type": "null" } ], - "title": "Display Name" + "title": "Contact Email" }, - "contact_email": { + "display_name": { "anyOf": [ { - "type": "string", - "maxLength": 320 + "maxLength": 200, + "type": "string" }, { "type": "null" } ], - "title": "Contact Email" + "title": "Display Name" } }, - "additionalProperties": false, - "type": "object", "title": "ActorProfileUpdateRequest", - "description": "Human-owned display fields accepted by the canonical self API." + "type": "object" }, "ApiError": { + "additionalProperties": false, + "description": "Canonical machine-readable API error.", "properties": { "code": { - "type": "string", - "title": "Code" + "title": "Code", + "type": "string" }, - "message": { - "type": "string", - "title": "Message" + "correlation_id": { + "title": "Correlation Id", + "type": "string" }, "details": { "additionalProperties": true, - "type": "object", - "title": "Details" + "title": "Details", + "type": "object" }, - "correlation_id": { - "type": "string", - "title": "Correlation Id" + "message": { + "title": "Message", + "type": "string" }, "retryable": { - "type": "boolean", - "title": "Retryable" + "title": "Retryable", + "type": "boolean" } }, - "additionalProperties": false, - "type": "object", "required": [ "code", "message", @@ -175,13 +175,12 @@ "retryable" ], "title": "ApiError", - "description": "Canonical machine-readable API error." + "type": "object" }, "ApiErrorResponse": { + "additionalProperties": false, + "description": "Canonical error plus additive legacy compatibility fields.", "properties": { - "error": { - "$ref": "#/components/schemas/ApiError" - }, "detail": { "anyOf": [ {}, @@ -190,44 +189,65 @@ } ], "title": "Detail" + }, + "error": { + "$ref": "#/components/schemas/ApiError" } }, - "additionalProperties": false, - "type": "object", "required": [ "error" ], "title": "ApiErrorResponse", - "description": "Canonical error plus additive legacy compatibility fields." + "type": "object" }, "HTTPValidationError": { - "type": "object", - "required": [ - "detail", - "error" - ], + "additionalProperties": false, "properties": { "detail": { - "type": "array", "items": { "type": "object" - } + }, + "type": "array" }, "error": { "$ref": "#/components/schemas/ApiError" } }, - "additionalProperties": false + "required": [ + "detail", + "error" + ], + "type": "object" } } }, "operation": { - "tags": [ - "actors" - ], - "summary": "Update Current Actor Profile", "description": "Update only the caller-owned canonical display fields.", "operationId": "update_current_actor_profile_api_v1_actors_me_patch", + "parameters": [ + { + "in": "header", + "name": "X-Request-ID", + "required": false, + "schema": { + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "type": "string" + } + }, + { + "in": "header", + "name": "X-Correlation-ID", + "required": false, + "schema": { + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -240,7 +260,6 @@ }, "responses": { "200": { - "description": "Successful Response", "content": { "application/json": { "schema": { @@ -248,180 +267,181 @@ } } }, + "description": "Successful Response", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } } }, "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + }, "description": "Invalid request.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } - }, + } + }, + "401": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "500": { - "description": "Internal server error.", + }, + "description": "Authentication failed.", "headers": { - "X-Request-ID": { - "required": true, + "WWW-Authenticate": { "schema": { - "type": "string", - "format": "uuid" + "type": "string" } }, "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } - } - }, - "content": { - "application/json": { + }, + "X-Request-ID": { + "required": true, "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" + "format": "uuid", + "type": "string" } } } }, - "422": { - "description": "Validation Error", + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HTTPValidationError" + "$ref": "#/components/schemas/ApiErrorResponse" } } }, + "description": "Permission denied.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } } }, - "401": { - "description": "Authentication failed.", - "headers": { - "X-Request-ID": { - "required": true, + "422": { + "content": { + "application/json": { "schema": { - "type": "string", - "format": "uuid" + "$ref": "#/components/schemas/HTTPValidationError" } - }, + } + }, + "description": "Validation Error", + "headers": { "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "WWW-Authenticate": { + "X-Request-ID": { + "required": true, "schema": { + "format": "uuid", "type": "string" } } - }, + } + }, + "500": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "403": { - "description": "Permission denied.", + }, + "description": "Internal server error.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } - }, + } + }, + "503": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "503": { + }, "description": "Identity verification unavailable.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" + "format": "uuid", + "type": "string" } } } @@ -432,31 +452,11 @@ "HTTPBearer": [] } ], - "x-workstream-action-id": "actor.profile.update_self", - "parameters": [ - { - "name": "X-Request-ID", - "in": "header", - "required": false, - "schema": { - "type": "string", - "format": "uuid", - "minLength": 36, - "maxLength": 36 - } - }, - { - "name": "X-Correlation-ID", - "in": "header", - "required": false, - "schema": { - "type": "string", - "format": "uuid", - "minLength": 36, - "maxLength": 36 - } - } - ] + "summary": "Update Current Actor Profile", + "tags": [ + "actors" + ], + "x-workstream-action-id": "actor.profile.update_self" }, "schema_version": 1, "source": { From a7e86bf3894f156eebc09a6920a6c313e5bfb63f Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 19:21:31 +0100 Subject: [PATCH 013/115] fix(migrations): retain audit restore upgrade step --- backend/alembic/env.py | 1 + backend/tests/test_alembic.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 43de781a8..752c26022 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -113,6 +113,7 @@ def do_run_migrations(connection: Connection) -> None: ("0057_roles_anywhere_custody",), ("0058_compensation_unit_create",), ("0059_neutral_guide_runtime",), + ("0060_audit_restore_path",), (_CURRENT_HEAD_REVISION,), ): raise RuntimeError(_RECREATE_GUIDANCE) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index eb7e25634..ba71f01fb 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -354,6 +354,7 @@ def test_v01_graph_has_one_root_and_head() -> None: assert [revision.revision for revision in revisions] == [ HEAD_REVISION, + "0060_audit_restore_path", "0059_neutral_guide_runtime", "0058_compensation_unit_create", "0057_roles_anywhere_custody", @@ -568,6 +569,7 @@ def test_repeated_upgrade_head_preserves_current_database( "0057_roles_anywhere_custody", "0058_compensation_unit_create", "0059_neutral_guide_runtime", + "0060_audit_restore_path", ], ) def test_preceding_head_upgrades_to_current_without_recreation( From e8c0317f8c7bb5f8de143465dbb4bb808315ada3 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 19:22:50 +0100 Subject: [PATCH 014/115] Admit setup model profile runtime selectors --- backend/alembic/versions/0061_model_usage.py | 50 ++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/backend/alembic/versions/0061_model_usage.py b/backend/alembic/versions/0061_model_usage.py index bbfa913d8..4c217a9df 100644 --- a/backend/alembic/versions/0061_model_usage.py +++ b/backend/alembic/versions/0061_model_usage.py @@ -13,6 +13,7 @@ def upgrade() -> None: """Create shared MODEL-USAGE owners before ART and AUTH extensions.""" op.execute("SET LOCAL search_path=pg_catalog,public,pg_temp") + _upgrade_setup_runtime_configuration_guard() for statement in _TABLES: op.execute(statement) for statement in _FUNCTIONS: @@ -24,6 +25,55 @@ def upgrade() -> None: _upgrade_authorization_vocabulary() +def _upgrade_setup_runtime_configuration_guard() -> None: + """Admit only an absent/null or exact complete model-profile selector.""" + signature = "public.guard_project_guide_runtime_configuration()" + definition = ( + op.get_bind() + .execute( + text( + "select pg_catalog.pg_get_functiondef(cast(:signature as pg_catalog.regprocedure))" + ), + {"signature": signature}, + ) + .scalar_one() + ) + allowed_anchor = "'maximum_tool_calls','maximum_tool_output_characters'] <> '{}'::jsonb" + allowed_replacement = ( + "'maximum_tool_calls','maximum_tool_output_characters'," + "'model_profile_id','model_profile_version','model_profile_digest'] <> '{}'::jsonb" + ) + validation_anchor = " OR jsonb_typeof(config->'adapter_profile') IS DISTINCT FROM 'string'" + profile_validation = r""" + OR NOT ( + ((NOT config ? 'model_profile_id' OR config->'model_profile_id'='null'::jsonb) + AND (NOT config ? 'model_profile_version' OR + config->'model_profile_version'='null'::jsonb) + AND (NOT config ? 'model_profile_digest' OR + config->'model_profile_digest'='null'::jsonb)) + OR + (jsonb_typeof(config->'model_profile_id')='string' + AND config->>'model_profile_id' ~ + '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + AND public._guide_runtime_integer( + config,'model_profile_version',1,2147483647) IS TRUE + AND jsonb_typeof(config->'model_profile_digest')='string' + AND config->>'model_profile_digest' ~ '^sha256:[0-9a-f]{64}$') + ) +""" + if ( + definition.count(allowed_anchor) != 1 + or definition.count(validation_anchor) != 1 + or "model_profile_id" in definition + ): + raise RuntimeError("setup runtime configuration predecessor changed") + op.execute( + definition.replace(allowed_anchor, allowed_replacement).replace( + validation_anchor, profile_validation + validation_anchor + ) + ) + + def _upgrade_invocation_grants() -> None: """Create bounded invocation sessions after their retained call-ledger parents.""" op.execute( From 062a672600fba9ae4a229f24c923c8f119f8eb2e Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 19:24:50 +0100 Subject: [PATCH 015/115] Fail closed on partial setup profile selectors --- backend/alembic/versions/0061_model_usage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/alembic/versions/0061_model_usage.py b/backend/alembic/versions/0061_model_usage.py index 4c217a9df..b391ca90c 100644 --- a/backend/alembic/versions/0061_model_usage.py +++ b/backend/alembic/versions/0061_model_usage.py @@ -45,7 +45,7 @@ def _upgrade_setup_runtime_configuration_guard() -> None: ) validation_anchor = " OR jsonb_typeof(config->'adapter_profile') IS DISTINCT FROM 'string'" profile_validation = r""" - OR NOT ( + OR ( ((NOT config ? 'model_profile_id' OR config->'model_profile_id'='null'::jsonb) AND (NOT config ? 'model_profile_version' OR config->'model_profile_version'='null'::jsonb) @@ -59,7 +59,7 @@ def _upgrade_setup_runtime_configuration_guard() -> None: config,'model_profile_version',1,2147483647) IS TRUE AND jsonb_typeof(config->'model_profile_digest')='string' AND config->>'model_profile_digest' ~ '^sha256:[0-9a-f]{64}$') - ) + ) IS NOT TRUE """ if ( definition.count(allowed_anchor) != 1 From 937b2ca9e0fc76781f41f45420c50a8f283c6c49 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 19:59:33 +0100 Subject: [PATCH 016/115] Add setup model output artifact custody --- .commitrail/changes/checker-model-runtime.md | 22 +++ backend/alembic/versions/0061_model_usage.py | 131 +++++++++++-- backend/app/adapters/artifacts/__init__.py | 72 ++++++- backend/app/adapters/model_usage/__init__.py | 8 + .../artifacts/api/checker_output_authority.py | 18 +- .../artifacts/checker_model_evidence.py | 43 ++++- .../artifacts/checker_output_bindings.py | 58 ++++-- .../artifacts/checker_output_custody.py | 38 ++-- .../app/modules/artifacts/checker_outputs.py | 75 +++++++- backend/app/modules/artifacts/models.py | 19 +- backend/app/modules/artifacts/schemas.py | 43 +---- backend/app/modules/artifacts/service.py | 46 +++-- .../checker_output_authorization.py | 30 ++- .../app/modules/authorization/domain/audit.py | 2 + .../authorization/domain/checker_outputs.py | 98 ++++++---- .../modules/checkers/api/output_custody.py | 92 ++++++++- backend/app/modules/model_usage/api.py | 130 +++++++++++++ .../app/modules/model_usage/output_custody.py | 111 +++++++++++ .../checker_models/test_contracts.py | 86 ++++++++- .../test_setup_output_custody.py | 176 ++++++++++++++++++ backend/tests/conftest.py | 2 +- .../test_runtime_configuration_postgresql.py | 36 ++++ 22 files changed, 1142 insertions(+), 194 deletions(-) create mode 100644 backend/app/modules/model_usage/output_custody.py create mode 100644 backend/tests/checkers_model_calls/test_setup_output_custody.py diff --git a/.commitrail/changes/checker-model-runtime.md b/.commitrail/changes/checker-model-runtime.md index 6b8b5e42d..28388dc52 100644 --- a/.commitrail/changes/checker-model-runtime.md +++ b/.commitrail/changes/checker-model-runtime.md @@ -302,6 +302,28 @@ Review current main and open migrations before allocating a revision number. Required execution trials and authenticated prior-work context must still be supplied before final guide activation. +### Active setup-output custody ownership + +The setup ART continuation is limited to public setup selector/reservation +types and a MODEL-USAGE-owned resolver, the existing ART generated-output +admission/storage/verification/binding path, its prepared AUTH resource, the +`0061_model_usage` ART constraints/guards, adapter composition, and focused +AUTH/PostgreSQL/MinIO regressions. Its owned paths are +`backend/app/modules/model_usage/api.py`, a new cohesive resolver under +`backend/app/modules/model_usage/`, +`backend/app/modules/checkers/api/output_custody.py`, the existing +`backend/app/modules/artifacts/` checker-output API, custody, binding, service, +schema, model and adapter-composition files, +`backend/app/adapters/model_usage/__init__.py`, +`backend/app/modules/authorization/checker_output_authorization.py`, +`backend/app/modules/authorization/domain/checker_outputs.py`, +`backend/app/modules/authorization/domain/audit.py`, +`backend/alembic/versions/0061_model_usage.py`, and only their focused tests and +schema inventory/fingerprint updates. This continuation does not edit +`backend/app/modules/model_usage/setup.py`, SDK/transport/provider dispatch, +PROJECTS owners, or CHECKERS ORM. It adds only the closed setup-model-output +case; independent trial-evidence types remain outside this branch. + ## Independent QA, test-delta and CI review Review dimensions: `qa`, `test_delta`, and `ci_integrity`. Review target: diff --git a/backend/alembic/versions/0061_model_usage.py b/backend/alembic/versions/0061_model_usage.py index b391ca90c..719df7e7b 100644 --- a/backend/alembic/versions/0061_model_usage.py +++ b/backend/alembic/versions/0061_model_usage.py @@ -425,12 +425,42 @@ def _upgrade_art_custody() -> None: ) op.execute( r""" +ALTER TABLE artifact_put_attempts DROP CONSTRAINT ck_artifact_put_attempts_producer_request_type + """ + ) + op.execute( + r""" +ALTER TABLE artifact_put_attempts ADD CONSTRAINT ck_artifact_put_attempts_producer_request_type CHECK (producer_request_type IN ('guide','checker_output','setup_model_output','submission_bundle','task_import_source')) + """ + ) + op.execute( + r""" +ALTER TABLE artifact_put_attempts DROP CONSTRAINT ck_artifact_put_attempts_producer_identity + """ + ) + op.execute( + r""" +ALTER TABLE artifact_put_attempts ADD CONSTRAINT ck_artifact_put_attempts_producer_identity CHECK (((producer_request_type IN ('guide','task_import_source') AND producer_type='actor_profile' AND producer_ref ~ '^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$') OR (producer_request_type IN ('checker_output','setup_model_output') AND producer_type='service_identity' AND producer_ref='workstream.artifact.checker_output') OR (producer_request_type='submission_bundle' AND producer_type='actor_profile' AND producer_ref ~ '^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'))) + """ + ) + op.execute( + r""" +ALTER TABLE artifact_put_attempts DROP CONSTRAINT ck_artifact_put_attempts_checker_request_digest + """ + ) + op.execute( + r""" +ALTER TABLE artifact_put_attempts ADD CONSTRAINT ck_artifact_put_attempts_checker_request_digest CHECK (((producer_request_type IN ('checker_output','setup_model_output') AND checker_request_digest IS NOT NULL AND checker_request_digest ~ '^sha256:[0-9a-f]{64}$') OR (producer_request_type NOT IN ('checker_output','setup_model_output') AND checker_request_digest IS NULL))) + """ + ) + op.execute( + r""" ALTER TABLE artifact_put_attempts DROP CONSTRAINT ck_artifact_put_attempts_producer_reference """ ) op.execute( r""" -ALTER TABLE artifact_put_attempts ADD CONSTRAINT ck_artifact_put_attempts_producer_reference CHECK ((producer_request_type = 'guide' and task_import_source_id is null and guide_source_item_id is not null and checker_run_id is null and model_call_attempt_id is null and task_id is null and submission_id is null and submission_version is null and logical_role is null) or (producer_request_type = 'checker_output' and task_import_source_id is null and guide_source_item_id is null and ((checker_run_id is not null and model_call_attempt_id is null) or (checker_run_id is null and model_call_attempt_id is not null and logical_role in ('model_request','model_response'))) and task_id is not null and submission_id is not null and submission_version is not null and octet_length(logical_role) between 1 and 100) or (producer_request_type = 'submission_bundle' and task_import_source_id is null and guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null and task_id is not null and submission_id is null and submission_version is null and logical_role is null) or (producer_request_type = 'task_import_source' and task_import_source_id is not null and guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null and task_id is null and submission_id is null and submission_version is null and logical_role is not distinct from 'task_import_source')) +ALTER TABLE artifact_put_attempts ADD CONSTRAINT ck_artifact_put_attempts_producer_reference CHECK ((producer_request_type = 'guide' and task_import_source_id is null and guide_source_item_id is not null and checker_run_id is null and model_call_attempt_id is null and task_id is null and submission_id is null and submission_version is null and logical_role is null) or (producer_request_type = 'checker_output' and task_import_source_id is null and guide_source_item_id is null and ((checker_run_id is not null and model_call_attempt_id is null) or (checker_run_id is null and model_call_attempt_id is not null and logical_role in ('model_request','model_response'))) and task_id is not null and submission_id is not null and submission_version is not null and octet_length(logical_role) between 1 and 100) or (producer_request_type = 'setup_model_output' and task_import_source_id is null and guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is not null and task_id is null and submission_id is null and submission_version is null and logical_role in ('model_request','model_response')) or (producer_request_type = 'submission_bundle' and task_import_source_id is null and guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null and task_id is not null and submission_id is null and submission_version is null and logical_role is null) or (producer_request_type = 'task_import_source' and task_import_source_id is not null and guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null and task_id is null and submission_id is null and submission_version is null and logical_role is not distinct from 'task_import_source')) """ ) op.execute( @@ -459,7 +489,7 @@ def _upgrade_art_custody() -> None: AND NEW.checker_run_id IS NULL AND NEW.model_call_attempt_id IS NULL AND NEW.logical_role IS NULL)) - OR (attempt.producer_request_type='checker_output' AND NOT ( + OR (attempt.producer_request_type IN ('checker_output','setup_model_output') AND NOT ( NEW.guide_source_item_id IS NULL AND ( (attempt.model_call_attempt_id IS NULL @@ -483,7 +513,7 @@ def _upgrade_art_custody() -> None: OR NEW.model_call_attempt_id IS NOT NULL OR NEW.logical_role IS DISTINCT FROM 'task_import_source')) OR attempt.producer_request_type NOT IN ( - 'guide','checker_output','submission_bundle','task_import_source') THEN + 'guide','checker_output','setup_model_output','submission_bundle','task_import_source') THEN RAISE EXCEPTION 'artifact receipt producer reference mismatch' USING ERRCODE='23514'; END IF; @@ -498,7 +528,7 @@ def _upgrade_art_custody() -> None: ) op.execute( r""" -ALTER TABLE artifact_bindings ADD CONSTRAINT ck_artifact_bindings_checker_output_lineage CHECK ((resource_type in ('checker_run','checker_model_call') and scope_version = 1 and put_attempt_id is not null and verification_receipt_id is not null) or (resource_type not in ('checker_run','checker_model_call') and put_attempt_id is null and verification_receipt_id is null)) +ALTER TABLE artifact_bindings ADD CONSTRAINT ck_artifact_bindings_checker_output_lineage CHECK ((resource_type in ('checker_run','checker_model_call','setup_model_call') and scope_version = 1 and put_attempt_id is not null and verification_receipt_id is not null) or (resource_type not in ('checker_run','checker_model_call','setup_model_call') and put_attempt_id is null and verification_receipt_id is null)) """ ) op.execute( @@ -507,7 +537,7 @@ def _upgrade_art_custody() -> None: LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ BEGIN IF TG_OP='DELETE' THEN - IF OLD.producer_request_type='checker_output' + IF OLD.producer_request_type IN ('checker_output','setup_model_output') OR OLD.checker_output_custody_sealed THEN RAISE EXCEPTION 'checker output put attempt custody is immutable' USING ERRCODE='55000'; @@ -530,7 +560,7 @@ def _upgrade_art_custody() -> None: (to_jsonb(OLD) - 'checker_output_custody_sealed') OR NOT EXISTS ( SELECT 1 FROM artifact_bindings binding - WHERE binding.resource_type IN ('checker_run','checker_model_call') + WHERE binding.resource_type IN ('checker_run','checker_model_call','setup_model_call') AND binding.put_attempt_id=OLD.id ) THEN RAISE EXCEPTION 'checker output put attempt seal requires binding' @@ -538,8 +568,8 @@ def _upgrade_art_custody() -> None: END IF; RETURN NEW; END IF; - IF OLD.producer_request_type='checker_output' - OR NEW.producer_request_type='checker_output' THEN + IF OLD.producer_request_type IN ('checker_output','setup_model_output') + OR NEW.producer_request_type IN ('checker_output','setup_model_output') THEN IF ROW( NEW.id, NEW.producer_request_type, NEW.producer_type, NEW.producer_ref, NEW.project_id, NEW.task_id, NEW.guide_source_item_id, NEW.checker_run_id, NEW.model_call_attempt_id, @@ -596,7 +626,7 @@ def _upgrade_art_custody() -> None: FROM artifact_bindings binding JOIN artifact_verification_receipts receipt ON receipt.id=binding.verification_receipt_id - WHERE binding.resource_type IN ('checker_run','checker_model_call') + WHERE binding.resource_type IN ('checker_run','checker_model_call','setup_model_call') AND receipt.verification_job_id=OLD.id ) THEN RAISE EXCEPTION 'checker output verification job seal requires binding' @@ -646,7 +676,7 @@ def _upgrade_art_custody() -> None: ON receipt.id=binding.verification_receipt_id JOIN artifact_verification_jobs job ON job.id=receipt.verification_job_id - WHERE binding.resource_type IN ('checker_run','checker_model_call') + WHERE binding.resource_type IN ('checker_run','checker_model_call','setup_model_call') AND job.replica_id=OLD.id ) THEN RAISE EXCEPTION 'checker output replica seal requires binding' @@ -669,7 +699,7 @@ def _upgrade_art_custody() -> None: content artifact_contents%ROWTYPE; write_receipt_matches integer; BEGIN - IF NEW.resource_type NOT IN ('checker_run','checker_model_call') THEN + IF NEW.resource_type NOT IN ('checker_run','checker_model_call','setup_model_call') THEN RETURN NEW; END IF; @@ -730,7 +760,7 @@ def _upgrade_art_custody() -> None: OR NEW.content_id IS DISTINCT FROM content.id OR NEW.project_id IS DISTINCT FROM attempt.project_id OR NEW.resource_id IS DISTINCT FROM ( - CASE WHEN NEW.resource_type='checker_model_call' + CASE WHEN NEW.resource_type IN ('checker_model_call','setup_model_call') THEN attempt.model_call_attempt_id::text ELSE attempt.checker_run_id::text END ) OR NEW.logical_role IS DISTINCT FROM attempt.logical_role @@ -738,7 +768,9 @@ def _upgrade_art_custody() -> None: OR NEW.attribution_type IS DISTINCT FROM 'service_identity' OR NEW.scope_version <> 1 OR NEW.supersedes_binding_id IS NOT NULL - OR attempt.producer_request_type <> 'checker_output' + OR attempt.producer_request_type IS DISTINCT FROM ( + CASE WHEN NEW.resource_type='setup_model_call' + THEN 'setup_model_output' ELSE 'checker_output' END) OR attempt.producer_type <> 'service_identity' OR attempt.submission_id IS NULL OR attempt.submission_version IS NULL OR attempt.checker_request_digest IS NULL @@ -778,7 +810,7 @@ def _upgrade_art_custody() -> None: verification artifact_verification_receipts%ROWTYPE; job artifact_verification_jobs%ROWTYPE; BEGIN - IF NEW.resource_type NOT IN ('checker_run','checker_model_call') THEN + IF NEW.resource_type NOT IN ('checker_run','checker_model_call','setup_model_call') THEN RETURN NEW; END IF; SELECT receipt.* INTO STRICT verification @@ -806,18 +838,79 @@ def _upgrade_art_custody() -> None: CREATE FUNCTION public.guard_checker_model_artifact_ancestry() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ DECLARE effect model_usage_effects%ROWTYPE; - slot checker_model_usage_slots%ROWTYPE; + checker_slot checker_model_usage_slots%ROWTYPE; + setup_slot setup_model_usage_slots%ROWTYPE; profile model_usage_profiles%ROWTYPE; + expected_maximum integer; + expected_operation varchar; + expected_custody varchar; BEGIN IF NEW.model_call_attempt_id IS NULL THEN RETURN NEW; END IF; SELECT * INTO STRICT effect FROM model_usage_effects WHERE id=NEW.model_call_attempt_id; - SELECT * INTO STRICT slot FROM checker_model_usage_slots + IF NEW.producer_request_type='setup_model_output' THEN + IF effect.setup_slot_id IS NULL OR effect.checker_slot_id IS NOT NULL THEN + RAISE EXCEPTION 'setup model artifact call ancestry mismatch' USING ERRCODE='23514'; + END IF; + SELECT * INTO STRICT setup_slot FROM setup_model_usage_slots + WHERE id=effect.setup_slot_id; + SELECT * INTO STRICT profile FROM model_usage_profiles + WHERE id=setup_slot.profile_id AND profile_version=setup_slot.profile_version + AND profile_digest=setup_slot.profile_digest; + expected_maximum := CASE WHEN NEW.logical_role='model_request' + THEN profile.maximum_request_bytes + ELSE profile.maximum_response_bytes END; + expected_operation := public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'request_type','setup_model_output', + 'model_call_attempt_id',effect.id, + 'logical_role',NEW.logical_role)); + expected_custody := public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'slot_key',pg_catalog.jsonb_build_object( + 'compilation_attempt_id',setup_slot.compilation_attempt_id, + 'project_id',setup_slot.project_id, + 'guide_id',setup_slot.guide_id, + 'source_snapshot_id',setup_slot.source_snapshot_id, + 'setup_run_id',setup_slot.setup_run_id, + 'setup_generation',setup_slot.setup_generation, + 'runtime_configuration_hash',setup_slot.runtime_configuration_hash, + 'profile_id',setup_slot.profile_id, + 'profile_version',setup_slot.profile_version, + 'profile_digest',setup_slot.profile_digest, + 'phase',setup_slot.phase, + 'call_index',setup_slot.call_index), + 'model_call_slot_id',setup_slot.id, + 'model_call_attempt_id',effect.id, + 'content_sha256',NEW.sha256, + 'role',NEW.logical_role, + 'media_type',NEW.media_type, + 'maximum_bytes',expected_maximum)); + IF NEW.project_id IS DISTINCT FROM setup_slot.project_id + OR NEW.task_id IS NOT NULL OR NEW.submission_id IS NOT NULL + OR NEW.submission_version IS NOT NULL OR NEW.checker_run_id IS NOT NULL + OR NEW.logical_role NOT IN ('model_request','model_response') + OR NEW.media_type IS DISTINCT FROM 'application/json' + OR NEW.byte_count < 1 OR NEW.byte_count > expected_maximum + OR effect.request_sha256 IS DISTINCT FROM setup_slot.request_sha256 + OR (NEW.logical_role='model_request' + AND NEW.sha256 IS DISTINCT FROM effect.request_sha256) + OR (NEW.logical_role='model_response' AND + (effect.state NOT IN ('dispatching','uncertain','completed','known_failed') + OR (effect.response_content_sha256 IS NOT NULL + AND NEW.sha256 IS DISTINCT FROM effect.response_content_sha256))) + OR NEW.operation_identity IS DISTINCT FROM expected_operation + OR NEW.checker_request_digest IS DISTINCT FROM expected_custody THEN + RAISE EXCEPTION 'setup model artifact call ancestry mismatch' USING ERRCODE='23514'; + END IF; + RETURN NEW; + END IF; + + SELECT * INTO STRICT checker_slot FROM checker_model_usage_slots WHERE id=effect.checker_slot_id; SELECT * INTO STRICT profile FROM model_usage_profiles - WHERE id=slot.profile_id AND profile_version=slot.profile_version - AND profile_digest=slot.profile_digest; + WHERE id=checker_slot.profile_id AND profile_version=checker_slot.profile_version + AND profile_digest=checker_slot.profile_digest; IF ROW(NEW.project_id,NEW.task_id,NEW.submission_id,NEW.submission_version) - IS DISTINCT FROM ROW(slot.project_id,slot.task_id,slot.submission_id,slot.submission_version) + IS DISTINCT FROM ROW(checker_slot.project_id,checker_slot.task_id,checker_slot.submission_id,checker_slot.submission_version) + OR NEW.producer_request_type IS DISTINCT FROM 'checker_output' OR NEW.checker_run_id IS NOT NULL OR NEW.media_type IS DISTINCT FROM 'application/json' OR NEW.byte_count < 1 diff --git a/backend/app/adapters/artifacts/__init__.py b/backend/app/adapters/artifacts/__init__.py index b66a6e2c5..966771f8a 100644 --- a/backend/app/adapters/artifacts/__init__.py +++ b/backend/app/adapters/artifacts/__init__.py @@ -3,7 +3,8 @@ from __future__ import annotations from contextlib import asynccontextmanager -from typing import Annotated +from collections.abc import Callable +from typing import Annotated, cast from uuid import UUID from fastapi import Depends, Request @@ -18,6 +19,11 @@ from app.db.session import get_db_session, get_session_factory from app.interfaces.artifact_operations import GuideArtifactIngestCommand from app.modules.projects.api.guide_documents import GuideDocumentUploadTargetPort +from app.modules.model_usage.api import ( + SetupModelArtifactOutputPort, + SetupModelOutputBindingPort, + SetupModelUsageOwnerPort, +) from app.modules.artifacts.api import SubmissionBundlePreparationCommand from app.modules.artifacts.api.task_import_source import TaskImportSourceCommandPort from app.modules.checkers.api.materialization import PostSubmissionMaterializationPort @@ -681,7 +687,8 @@ def worker_post_submission_materialization(*, sessions) -> PostSubmissionMateria def checker_output_storage( - *, sessions, store, namespace, preparation, settings + *, sessions, store, namespace, preparation, settings, + reservations=checker_output_reservations, ) -> CheckerArtifactOutputPort: """Compose private call evidence through canonical admission and verification.""" from app.adapters.auth import checker_output_write_authorization @@ -703,7 +710,7 @@ def authority(session, identity): namespace=namespace, preparation=preparation, settings=settings, - reservations=checker_output_reservations, + reservations=reservations, authority=checker_output_write_authorization, internal_authority=lambda session: authority( session, ServiceIdentity.ARTIFACT_PUT_RESOLVER @@ -714,7 +721,9 @@ def authority(session, identity): ) -def checker_output_binding(session, *, namespace) -> CheckerOutputBindingPort: +def checker_output_binding( + session, *, namespace, reservations=None +) -> CheckerOutputBindingPort: """Bind one exact private call effect to independently verified ART bytes.""" from app.adapters.auth import checker_output_binding_authorization from app.modules.artifacts.checker_output_bindings import CheckerOutputBindingService @@ -722,11 +731,57 @@ def checker_output_binding(session, *, namespace) -> CheckerOutputBindingPort: return CheckerOutputBindingService( session, namespace_fingerprint=namespace.namespace_fingerprint, - reservations=checker_output_reservations(session), + reservations=reservations or checker_output_reservations(session), authority=checker_output_binding_authorization(session), ) +def setup_model_output_storage( + *, + sessions, + store, + namespace, + preparation, + settings, + owner: Callable[[AsyncSession], SetupModelUsageOwnerPort], +) -> SetupModelArtifactOutputPort: + """Compose setup request/response custody without importing PROJECTS into MODEL-USAGE.""" + from app.adapters.model_usage import setup_model_output_reservations + + return cast( + SetupModelArtifactOutputPort, + checker_output_storage( + sessions=sessions, + store=store, + namespace=namespace, + preparation=preparation, + settings=settings, + reservations=lambda session: setup_model_output_reservations( + session, owner=owner(session) + ), + ), + ) + + +def setup_model_output_binding( + session, + *, + namespace, + owner: SetupModelUsageOwnerPort, +) -> SetupModelOutputBindingPort: + """Compose exact setup-effect publication through the shared ART binding path.""" + from app.adapters.model_usage import setup_model_output_reservations + + return cast( + SetupModelOutputBindingPort, + checker_output_binding( + session, + namespace=namespace, + reservations=setup_model_output_reservations(session, owner=owner), + ), + ) + + def checker_model_evidence(session): """Expose verified call binding facts to CHECKERS without transcript bytes.""" from app.modules.artifacts.checker_model_evidence import CheckerModelEvidence @@ -734,6 +789,13 @@ def checker_model_evidence(session): return CheckerModelEvidence(session) +def setup_model_evidence(session): + """Expose verified setup-effect bindings to MODEL-USAGE without transcript bytes.""" + from app.modules.artifacts.checker_model_evidence import SetupModelEvidence + + return SetupModelEvidence(session) + + async def get_task_import_source_commands( request: Request, session: Annotated[AsyncSession, Depends(get_db_session)], diff --git a/backend/app/adapters/model_usage/__init__.py b/backend/app/adapters/model_usage/__init__.py index 329f9b307..ea2f00c46 100644 --- a/backend/app/adapters/model_usage/__init__.py +++ b/backend/app/adapters/model_usage/__init__.py @@ -51,6 +51,13 @@ def setup_model_usage_service(session, *, authority, evidence, owner): ) +def setup_model_output_reservations(session, *, owner): + """Compose MODEL-USAGE-owned setup ART reservations behind its public port.""" + from app.modules.model_usage.output_custody import SetupModelOutputReservations + + return SetupModelOutputReservations(session, owner=owner) + + __all__ = ( "model_call_service", "model_evidence_custody", @@ -58,4 +65,5 @@ def setup_model_usage_service(session, *, authority, evidence, owner): "model_profile_reader", "model_recovery_sources", "setup_model_usage_service", + "setup_model_output_reservations", ) diff --git a/backend/app/modules/artifacts/api/checker_output_authority.py b/backend/app/modules/artifacts/api/checker_output_authority.py index 479908861..8ee9dd0c6 100644 --- a/backend/app/modules/artifacts/api/checker_output_authority.py +++ b/backend/app/modules/artifacts/api/checker_output_authority.py @@ -5,8 +5,8 @@ from uuid import UUID from app.modules.checkers.api.output_custody import ( - CheckerOutputReservation, - CheckerOutputSelector, + GeneratedOutputReservation, + GeneratedOutputSelector, ) @@ -15,7 +15,7 @@ class CheckerOutputCommitmentAuthorityFacts: """Server-computed commitment without source bytes or scratch identity.""" - reservation: CheckerOutputReservation + reservation: GeneratedOutputReservation slot_key: str sha256: str byte_count: int @@ -41,14 +41,14 @@ class CheckerOutputStoredFacts: class CheckerOutputWriteAuthority(Protocol): """Fresh write authority; no prepared handle survives storage I/O.""" - async def preflight(self, selector: CheckerOutputSelector) -> None: ... + async def preflight(self, selector: GeneratedOutputSelector) -> None: ... - async def prepare(self, selector: CheckerOutputSelector) -> None: ... + async def prepare(self, selector: GeneratedOutputSelector) -> None: ... async def consume(self, facts: CheckerOutputCommitmentAuthorityFacts) -> None: ... async def consume_existing( - self, reservation: CheckerOutputReservation, facts: CheckerOutputStoredFacts + self, reservation: GeneratedOutputReservation, facts: CheckerOutputStoredFacts ) -> None: ... def discard(self) -> None: ... @@ -57,12 +57,12 @@ def discard(self) -> None: ... class CheckerOutputBindingAuthority(Protocol): """Fresh publication authority for independently verified call evidence.""" - async def preflight(self, selector: CheckerOutputSelector) -> None: ... + async def preflight(self, selector: GeneratedOutputSelector) -> None: ... - async def prepare(self, selector: CheckerOutputSelector) -> None: ... + async def prepare(self, selector: GeneratedOutputSelector) -> None: ... async def consume( - self, reservation: CheckerOutputReservation, facts: CheckerOutputStoredFacts + self, reservation: GeneratedOutputReservation, facts: CheckerOutputStoredFacts ) -> None: ... def discard(self) -> None: ... diff --git a/backend/app/modules/artifacts/checker_model_evidence.py b/backend/app/modules/artifacts/checker_model_evidence.py index 6f4cc1795..28e67ff14 100644 --- a/backend/app/modules/artifacts/checker_model_evidence.py +++ b/backend/app/modules/artifacts/checker_model_evidence.py @@ -17,6 +17,7 @@ ModelCallEvidence, ModelCallUnavailable, PreparedModelCallAttempt, + PreparedSetupModelUsageEffect, ) @@ -45,6 +46,9 @@ async def _require( attempt_id: UUID, role: str, evidence: ModelCallEvidence, + *, + resource_type: str = "checker_model_call", + producer_request_type: str = "checker_output", ) -> None: """Match all identities and integrity states without acquiring another owner lock.""" if not self._session.in_transaction() or self._session.in_nested_transaction(): @@ -65,7 +69,7 @@ async def _require( .join(ArtifactReplica, ArtifactReplica.id == ArtifactVerificationJob.replica_id) .where( ArtifactBinding.id == str(evidence.binding_id), - ArtifactBinding.resource_type == "checker_model_call", + ArtifactBinding.resource_type == resource_type, ArtifactBinding.resource_id == str(attempt_id), ArtifactBinding.logical_role == role, ArtifactBinding.scope_version == 1, @@ -81,7 +85,7 @@ async def _require( ArtifactContent.media_type == evidence.media_type, ArtifactPutAttempt.model_call_attempt_id == str(attempt_id), ArtifactPutAttempt.checker_run_id.is_(None), - ArtifactPutAttempt.producer_request_type == "checker_output", + ArtifactPutAttempt.producer_request_type == producer_request_type, ArtifactPutAttempt.logical_role == role, ArtifactPutAttempt.sha256 == evidence.content_sha256, ArtifactPutAttempt.byte_count == evidence.byte_count, @@ -111,3 +115,38 @@ async def _require( ) if binding is None: raise ModelCallUnavailable("model_call_evidence_unavailable") + + +class SetupModelEvidence(CheckerModelEvidence): + """Require sealed setup request/response bindings without PROJECTS callbacks.""" + + async def require_setup_request( + self, + prepared: PreparedSetupModelUsageEffect, + evidence: ModelCallEvidence, + ) -> None: + if evidence.content_sha256 != prepared.request_sha256: + raise ModelCallUnavailable("setup_model_evidence_unavailable") + await self._require_setup(prepared.effect_id, "model_request", evidence) + + async def require_setup_response( + self, effect_id: UUID, evidence: ModelCallEvidence + ) -> None: + await self._require_setup(effect_id, "model_response", evidence) + + async def _require_setup( + self, effect_id: UUID, role: str, evidence: ModelCallEvidence + ) -> None: + try: + await self._require( + effect_id, + role, + evidence, + resource_type="setup_model_call", + producer_request_type="setup_model_output", + ) + except ModelCallUnavailable as exc: + raise ModelCallUnavailable("setup_model_evidence_unavailable") from exc + + +__all__ = ("CheckerModelEvidence", "SetupModelEvidence") diff --git a/backend/app/modules/artifacts/checker_output_bindings.py b/backend/app/modules/artifacts/checker_output_bindings.py index 4c0c26be9..3a02aed1f 100644 --- a/backend/app/modules/artifacts/checker_output_bindings.py +++ b/backend/app/modules/artifacts/checker_output_bindings.py @@ -13,9 +13,15 @@ CheckerOutputBindingRequest, CheckerOutputBindingResult, CheckerOutputReservationPort, - CheckerOutputSelector, + GENERATED_OUTPUT_SELECTOR, + generated_output_subject, CheckerOutputUnavailable, ) +from app.modules.model_usage.api import ( + SetupModelOutputBindingRequest, + SetupModelOutputBindingResult, + SetupModelOutputReservationPort, +) from app.modules.actors.api import ServiceIdentity from app.modules.artifacts.checker_output_custody import select_checker_output from app.modules.artifacts.models import ArtifactBinding @@ -28,7 +34,7 @@ def __init__( self, session: AsyncSession, *, - reservations: CheckerOutputReservationPort, + reservations: CheckerOutputReservationPort | SetupModelOutputReservationPort, authority: CheckerOutputBindingAuthority, namespace_fingerprint: str, ) -> None: @@ -38,25 +44,47 @@ def __init__( async def bind_checker_output( self, request: CheckerOutputBindingRequest ) -> CheckerOutputBindingResult: - """Fence current worker facts before acquiring ART binding/verification locks.""" + """Bind exact checker evidence through the shared publication operation.""" + if type(request) is not CheckerOutputBindingRequest: + raise TypeError("invalid checker output binding request") + return await self._bind(request) + + async def bind_setup_output( + self, request: SetupModelOutputBindingRequest + ) -> SetupModelOutputBindingResult: + if type(request) is not SetupModelOutputBindingRequest: + raise TypeError("invalid setup model output binding request") + result = await self._bind(request) + return SetupModelOutputBindingResult( + result.binding_id, + result.content_id, + result.put_attempt_id, + result.verification_receipt_id, + result.replayed, + ) + + async def _bind( + self, request: CheckerOutputBindingRequest | SetupModelOutputBindingRequest + ) -> CheckerOutputBindingResult: + """Fence owner facts before acquiring ART binding/verification locks.""" if ( - type(request) is not CheckerOutputBindingRequest - or not self._session.in_transaction() + not self._session.in_transaction() or self._session.in_nested_transaction() or type(request.put_attempt_id) is not UUID or type(request.verification_receipt_id) is not UUID ): raise CheckerOutputUnavailable("checker_output_binding_unavailable") - selector = CheckerOutputSelector.model_validate(request.selector) + selector = GENERATED_OUTPUT_SELECTOR.validate_python(request.selector) try: await self._authority.preflight(selector) await self._authority.prepare(selector) reservation = await self._reservations.resolve(selector) reservation.select(selector) + owner = generated_output_subject(reservation) await self._session.execute( text("select pg_advisory_xact_lock(hashtextextended(:key, 0))"), { - "key": f"checker-output:{selector.paid_slot.project_id}:{selector.model_call_attempt_id}:{selector.slot_key}", + "key": f"generated-output:{owner.project_id}:{owner.resource_id}:{owner.logical_role}", }, ) facts = await select_checker_output( @@ -78,10 +106,10 @@ async def bind_checker_output( binding = await self._session.scalar( select(ArtifactBinding) .where( - ArtifactBinding.project_id == str(selector.paid_slot.project_id), - ArtifactBinding.resource_type == "checker_model_call", - ArtifactBinding.resource_id == str(selector.model_call_attempt_id), - ArtifactBinding.logical_role == selector.slot_key, + ArtifactBinding.project_id == str(owner.project_id), + ArtifactBinding.resource_type == owner.resource_type, + ArtifactBinding.resource_id == str(owner.resource_id), + ArtifactBinding.logical_role == owner.logical_role, ArtifactBinding.scope_version == 1, ) .with_for_update() @@ -107,10 +135,10 @@ async def bind_checker_output( binding = ArtifactBinding( id=str(new_record_id()), content_id=str(facts.content_id), - project_id=str(selector.paid_slot.project_id), - resource_type="checker_model_call", - resource_id=str(selector.model_call_attempt_id), - logical_role=selector.slot_key, + project_id=str(owner.project_id), + resource_type=owner.resource_type, + resource_id=str(owner.resource_id), + logical_role=owner.logical_role, scope_version=1, actor_id=ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value, attribution_type="service_identity", diff --git a/backend/app/modules/artifacts/checker_output_custody.py b/backend/app/modules/artifacts/checker_output_custody.py index ee27ed601..568c8b0e3 100644 --- a/backend/app/modules/artifacts/checker_output_custody.py +++ b/backend/app/modules/artifacts/checker_output_custody.py @@ -15,13 +15,10 @@ ArtifactVerificationJob, ArtifactVerificationReceipt, ) -from app.modules.artifacts.schemas import ( - checker_output_operation_identity, - checker_output_request_digest_facts, -) from app.modules.checkers.api.output_custody import ( - CheckerOutputReservation, - CheckerOutputSelector, + GeneratedOutputReservation, + GeneratedOutputSelector, + generated_output_subject, CheckerOutputUnavailable, ) @@ -29,8 +26,8 @@ async def select_checker_output( session: AsyncSession, *, - selector: CheckerOutputSelector, - reservation: CheckerOutputReservation, + selector: GeneratedOutputSelector, + reservation: GeneratedOutputReservation, namespace_fingerprint: str, put_attempt_id: UUID | None = None, verification_receipt_id: UUID | None = None, @@ -38,24 +35,17 @@ async def select_checker_output( ) -> CheckerOutputStoredFacts | None: """Match immutable request and verified ancestry; never read foreign owners.""" slot = reservation.select(selector) - expected_digest = canonical_json_hash( - checker_output_request_digest_facts( - reservation=reservation, - slot=slot, - ) - ) + owner = generated_output_subject(reservation) + expected_digest = canonical_json_hash(owner.request_digest_facts) attempt = await session.scalar( select(ArtifactPutAttempt) .where( - ArtifactPutAttempt.operation_identity == checker_output_operation_identity( - model_call_attempt_id=selector.model_call_attempt_id, slot_key=selector.slot_key - ), + ArtifactPutAttempt.operation_identity == owner.operation_identity, ) .execution_options(populate_existing=True) ) if attempt is None: return None - paid_slot = selector.paid_slot if ( ( attempt.producer_request_type, @@ -70,12 +60,12 @@ async def select_checker_output( attempt.namespace_fingerprint, ) != ( - "checker_output", - str(paid_slot.project_id), - str(selector.task_id), - str(paid_slot.submission_id), - paid_slot.submission_version, - str(selector.model_call_attempt_id), + owner.producer_request_type, + str(owner.project_id), + str(owner.task_id) if owner.task_id else None, + str(owner.submission_id) if owner.submission_id else None, + owner.submission_version, + str(owner.resource_id), slot.key, expected_digest, slot.media_type, diff --git a/backend/app/modules/artifacts/checker_outputs.py b/backend/app/modules/artifacts/checker_outputs.py index 93230be53..470073fd4 100644 --- a/backend/app/modules/artifacts/checker_outputs.py +++ b/backend/app/modules/artifacts/checker_outputs.py @@ -17,11 +17,19 @@ CheckerOutputReservationPort, CheckerOutputSelector, CheckerOutputUnavailable, + GeneratedOutputSelector, + GENERATED_OUTPUT_SELECTOR, ) from app.interfaces.artifacts import ArtifactStore, ArtifactByteRange, ArtifactIntegrityError from app.modules.artifacts.checker_output_custody import select_checker_output from app.modules.artifacts.models import ArtifactVerificationJob, ArtifactReplica -from app.modules.model_usage.api import ModelCallEvidence +from app.modules.model_usage.api import ( + ModelCallEvidence, + SetupModelOutputArtifactRequest, + SetupModelOutputArtifactResult, + SetupModelOutputReservationPort, + SetupModelOutputSelector, +) from app.modules.artifacts.preparation import ArtifactPreparationService from app.modules.artifacts.schemas import ( ArtifactInternalAuthority, @@ -41,7 +49,9 @@ def __init__( self, *, sessions: async_sessionmaker[AsyncSession], - reservations: Callable[[AsyncSession], CheckerOutputReservationPort], + reservations: Callable[ + [AsyncSession], CheckerOutputReservationPort | SetupModelOutputReservationPort + ], authority: Callable[[AsyncSession], CheckerOutputWriteAuthority], internal_authority: Callable[[AsyncSession], ArtifactInternalAuthority], verification_authority: Callable[[AsyncSession], ArtifactInternalAuthority], @@ -59,7 +69,28 @@ async def store(self, request: CheckerOutputArtifactRequest) -> CheckerOutputArt """Commit checked output bytes before I/O, never a CHECKERS completion.""" if type(request) is not CheckerOutputArtifactRequest: raise TypeError("invalid checker output request") - selector = CheckerOutputSelector.model_validate(request.selector) + return await self._store_output(request) + + async def store_setup_output( + self, request: SetupModelOutputArtifactRequest + ) -> SetupModelOutputArtifactResult: + """Store setup request/response bytes through the same verified path.""" + if type(request) is not SetupModelOutputArtifactRequest: + raise TypeError("invalid setup model output request") + result = await self._store_output(request) + return SetupModelOutputArtifactResult( + result.put_attempt_id, + result.status, + result.content_id, + result.verification_receipt_id, + result.replayed, + ) + + async def _store_output( + self, + request: CheckerOutputArtifactRequest | SetupModelOutputArtifactRequest, + ) -> CheckerOutputArtifactResult: + selector = GENERATED_OUTPUT_SELECTOR.validate_python(request.selector) prepared = None async with self._sessions() as session: authority = self._authority(session) @@ -114,7 +145,27 @@ async def store(self, request: CheckerOutputArtifactRequest) -> CheckerOutputArt async def recover(self, selector: CheckerOutputSelector) -> CheckerOutputArtifactResult | None: """Recover a lost response by logical identity without regenerating bytes.""" - selector = CheckerOutputSelector.model_validate(selector) + return await self._recover(CheckerOutputSelector.model_validate(selector)) + + async def recover_setup_output( + self, selector: SetupModelOutputSelector + ) -> SetupModelOutputArtifactResult | None: + result = await self._recover(SetupModelOutputSelector.model_validate(selector)) + return ( + None + if result is None + else SetupModelOutputArtifactResult( + result.put_attempt_id, + result.status, + result.content_id, + result.verification_receipt_id, + result.replayed, + ) + ) + + async def _recover( + self, selector: GeneratedOutputSelector + ) -> CheckerOutputArtifactResult | None: async with self._sessions() as session: authority = self._authority(session) try: @@ -150,6 +201,20 @@ async def read_response( evidence = ModelCallEvidence.model_validate(evidence) if selector.slot_key != "model_response" or selector.content_sha256 != evidence.content_sha256: raise CheckerOutputUnavailable("checker_output_response_unavailable") + return await self._read(selector, evidence) + + async def read_setup_response( + self, selector: SetupModelOutputSelector, evidence: ModelCallEvidence + ) -> bytes: + selector = SetupModelOutputSelector.model_validate(selector) + evidence = ModelCallEvidence.model_validate(evidence) + if selector.role != "model_response" or selector.content_sha256 != evidence.content_sha256: + raise CheckerOutputUnavailable("checker_output_response_unavailable") + return await self._read(selector, evidence) + + async def _read( + self, selector: GeneratedOutputSelector, evidence: ModelCallEvidence + ) -> bytes: async with self._sessions() as session: authority = self._authority(session) try: @@ -241,7 +306,7 @@ async def _result( self, session: AsyncSession, authority: CheckerOutputWriteAuthority, - selector: CheckerOutputSelector, + selector: GeneratedOutputSelector, *, replayed: bool, ) -> CheckerOutputArtifactResult: diff --git a/backend/app/modules/artifacts/models.py b/backend/app/modules/artifacts/models.py index e30c8387d..a37aa02e7 100644 --- a/backend/app/modules/artifacts/models.py +++ b/backend/app/modules/artifacts/models.py @@ -424,9 +424,9 @@ class ArtifactBinding(Base): name="scope_version_predecessor", ), CheckConstraint( - "(resource_type in ('checker_run','checker_model_call') and scope_version = 1 " + "(resource_type in ('checker_run','checker_model_call','setup_model_call') and scope_version = 1 " "and put_attempt_id is not null and verification_receipt_id is not null) or " - "(resource_type not in ('checker_run','checker_model_call') and put_attempt_id is null " + "(resource_type not in ('checker_run','checker_model_call','setup_model_call') and put_attempt_id is null " "and verification_receipt_id is null)", name="checker_output_lineage", ), @@ -1324,7 +1324,8 @@ class ArtifactPutAttempt(Base): ondelete="RESTRICT", name="fk_artifact_put_attempt_import_source_custody", ), CheckConstraint( - "producer_request_type in ('guide', 'checker_output', 'submission_bundle', 'task_import_source')", + "producer_request_type in ('guide', 'checker_output', 'setup_model_output', " + "'submission_bundle', 'task_import_source')", name="producer_request_type", ), CheckConstraint( @@ -1335,7 +1336,7 @@ class ArtifactPutAttempt(Base): "((producer_request_type in ('guide', 'task_import_source') " "and producer_type = 'actor_profile' and " + ACTOR_UUID7_CHECK.format(column="producer_ref") - + ") or (producer_request_type = 'checker_output' " + + ") or (producer_request_type in ('checker_output', 'setup_model_output') " "and producer_type = 'service_identity' " "and producer_ref = 'workstream.artifact.checker_output') or " "(producer_request_type = 'submission_bundle' " @@ -1359,9 +1360,11 @@ class ArtifactPutAttempt(Base): name="request_digest_shape", ), CheckConstraint( - "(producer_request_type = 'checker_output' and checker_request_digest is not null " + "(producer_request_type in ('checker_output','setup_model_output') " + "and checker_request_digest is not null " "and checker_request_digest ~ '^sha256:[0-9a-f]{64}$') or " - "(producer_request_type <> 'checker_output' and checker_request_digest is null)", + "(producer_request_type not in ('checker_output','setup_model_output') " + "and checker_request_digest is null)", name="checker_request_digest", ), CheckConstraint( @@ -1414,6 +1417,10 @@ class ArtifactPutAttempt(Base): "and logical_role in ('model_request','model_response'))) and task_id is not null " "and submission_id is not null and submission_version is not null " "and octet_length(logical_role) between 1 and 100) or " + "(producer_request_type = 'setup_model_output' and task_import_source_id is null " + "and guide_source_item_id is null and checker_run_id is null " + "and model_call_attempt_id is not null and task_id is null and submission_id is null " + "and submission_version is null and logical_role in ('model_request','model_response')) or " "(producer_request_type = 'submission_bundle' and task_import_source_id is null " "and guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null " "and task_id is not null and submission_id is null " diff --git a/backend/app/modules/artifacts/schemas.py b/backend/app/modules/artifacts/schemas.py index f111a993d..2694ef50c 100644 --- a/backend/app/modules/artifacts/schemas.py +++ b/backend/app/modules/artifacts/schemas.py @@ -7,11 +7,9 @@ from typing import Protocol, TypeAlias, final from uuid import UUID -from app.core.hashing import canonical_json_hash from app.modules.artifacts.sources import CommittedArtifactSource from app.modules.checkers.api.output_custody import ( - CheckerOutputReservation, - CheckerOutputSlot, + GeneratedOutputReservation, ) from app.modules.authorization.catalogue import ActionId from app.modules.actors.api import ServiceIdentity @@ -49,41 +47,11 @@ class GuideArtifactAdmissionRequest: class CheckerOutputArtifactAdmissionRequest: """One prepared model transcript admitted under its exact durable call effect.""" - reservation: CheckerOutputReservation + reservation: GeneratedOutputReservation slot_key: str source: CommittedArtifactSource -def checker_output_operation_identity(*, model_call_attempt_id: UUID, slot_key: str) -> str: - """Keep one transcript identity across checker generations and lease changes.""" - return canonical_json_hash( - { - "request_type": "checker_output", - "model_call_attempt_id": str(model_call_attempt_id), - "logical_role": slot_key, - } - ) - - -def checker_output_request_digest_facts( - *, - reservation: CheckerOutputReservation, - slot: CheckerOutputSlot, -) -> dict[str, object]: - """Return stable owner facts shared by checker admission and binding.""" - selector = reservation.selector - return { - "model_call_slot_id": str(selector.model_call_slot_id), - "model_call_attempt_id": str(selector.model_call_attempt_id), - "paid_slot": selector.paid_slot.model_dump(mode="json"), - "task_id": str(selector.task_id), - "content_sha256": selector.content_sha256, - "slot_key": slot.key, - "media_type": slot.media_type, - "maximum_bytes": slot.maximum_bytes, - } - - @final @dataclass(frozen=True, slots=True) class SubmissionBundleArtifactAdmissionRequest: @@ -365,4 +333,9 @@ class ArtifactRecoveryNotFoundError(ArtifactRecoveryError): # Only these current producers use post-put full-object verification. -VERIFICATION_PRODUCERS = ("checker_output", "submission_bundle", "task_import_source") +VERIFICATION_PRODUCERS = ( + "checker_output", + "setup_model_output", + "submission_bundle", + "task_import_source", +) diff --git a/backend/app/modules/artifacts/service.py b/backend/app/modules/artifacts/service.py index 40cad780d..ad18f70d2 100644 --- a/backend/app/modules/artifacts/service.py +++ b/backend/app/modules/artifacts/service.py @@ -109,17 +109,17 @@ GuideArtifactIngestAuthorityFacts, SubmissionBundleArtifactAdmissionRequest, SubmissionBundleDurableIntentAuthorityFacts, - checker_output_operation_identity, - checker_output_request_digest_facts, ) from app.modules.artifacts.submission_authorization import ( SubmissionBundlePreparedAuthorization, ) from app.modules.artifacts.sources import CommittedArtifactSource, PreparedArtifact from app.modules.checkers.api.output_custody import ( - CheckerOutputReservation, + GENERATED_OUTPUT_RESERVATION, + generated_output_subject, CheckerOutputUnavailable, ) +from app.modules.model_usage.api import ModelCallUnavailable from app.modules.authorization.runtime import ( AuthorizationContext, HumanAuthorizationContext, @@ -2409,7 +2409,7 @@ async def admit( "checker_output_request": facts.checker_request_digest_facts, "model_call_attempt_id": facts.model_call_attempt_id, } - if facts.request_type == "checker_output" + if facts.request_type in {"checker_output", "setup_model_output"} else {} ), "pre_submit_evidence_set_id": facts.pre_submit_evidence_set_id, @@ -2558,13 +2558,14 @@ def _validate_request_boundary(request: ArtifactAdmissionRequest) -> None: if type(request.source) is not CommittedArtifactSource: raise TypeError("invalid artifact admission source") if type(request) is CheckerOutputArtifactAdmissionRequest: - if type(request.reservation) is not CheckerOutputReservation: - raise TypeError("invalid checker output reservation") try: - request.reservation.select(request.reservation.selector) - if request.slot_key != request.reservation.selector.slot_key: + reservation = GENERATED_OUTPUT_RESERVATION.validate_python( + request.reservation + ) + reservation.select(reservation.selector) + if request.slot_key != reservation.slot.key: raise CheckerOutputUnavailable("checker_output_slot_unavailable") - except (CheckerOutputUnavailable, ValueError) as exc: + except (CheckerOutputUnavailable, ModelCallUnavailable, ValueError) as exc: raise ArtifactAdmissionRelationshipError( "checker output reservation is unavailable" ) from exc @@ -2631,13 +2632,13 @@ async def _checker_output_facts( self, request: CheckerOutputArtifactAdmissionRequest ) -> _AdmissionFacts: """Bind committed transcript bytes to one durable call effect and role.""" - reservation = CheckerOutputReservation.model_validate(request.reservation) + reservation = GENERATED_OUTPUT_RESERVATION.validate_python(request.reservation) selector = reservation.selector try: slot = reservation.select(selector) if slot.key != request.slot_key: raise CheckerOutputUnavailable("checker_output_slot_unavailable") - except (CheckerOutputUnavailable, ValueError) as exc: + except (CheckerOutputUnavailable, ModelCallUnavailable, ValueError) as exc: raise ArtifactAdmissionRelationshipError( "checker output reservation is unavailable" ) from exc @@ -2650,29 +2651,24 @@ async def _checker_output_facts( raise ArtifactAdmissionRelationshipError( "checker output does not match reserved slot" ) - paid_slot = selector.paid_slot logical_role = slot.key - operation_identity = checker_output_operation_identity( - model_call_attempt_id=selector.model_call_attempt_id, slot_key=logical_role - ) - digest_facts = checker_output_request_digest_facts( - reservation=reservation, - slot=slot, - ) + owner = generated_output_subject(reservation) + operation_identity = owner.operation_identity + digest_facts = owner.request_digest_facts return _AdmissionFacts( - request_type="checker_output", + request_type=owner.producer_request_type, producer_type="service_identity", producer_ref=ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value, - project_id=str(paid_slot.project_id), + project_id=str(owner.project_id), guide_id=None, - task_id=str(selector.task_id), + task_id=str(owner.task_id) if owner.task_id else None, guide_source_item_id=None, guide_source_snapshot_id=None, checker_run_id=None, - model_call_attempt_id=str(selector.model_call_attempt_id), + model_call_attempt_id=str(owner.resource_id), logical_role=logical_role, - submission_id=str(paid_slot.submission_id), - submission_version=paid_slot.submission_version, + submission_id=str(owner.submission_id) if owner.submission_id else None, + submission_version=owner.submission_version, checker_request_digest=canonical_json_hash(digest_facts), checker_request_digest_facts=digest_facts, pre_submit_evidence_set_id=None, diff --git a/backend/app/modules/authorization/checker_output_authorization.py b/backend/app/modules/authorization/checker_output_authorization.py index 5ead05b91..790618020 100644 --- a/backend/app/modules/authorization/checker_output_authorization.py +++ b/backend/app/modules/authorization/checker_output_authorization.py @@ -40,10 +40,13 @@ PreparedAuthorityScopeKind, ) from app.modules.checkers.api.output_custody import ( - CheckerOutputReservation, - CheckerOutputSelector, + GENERATED_OUTPUT_RESERVATION, + GENERATED_OUTPUT_SELECTOR, + GeneratedOutputReservation, + GeneratedOutputSelector, CheckerOutputUnavailable, ) +from app.modules.model_usage.api import ModelCallUnavailable _AUTH_ERRORS = ( @@ -80,12 +83,12 @@ def __init__( self._prepared: PreparedAuthorizationService | None = None self._handle: PreparedAuthorizationHandle | None = None self._input: PreparedAuthorizationInput | None = None - self._selector: CheckerOutputSelector | None = None + self._selector: GeneratedOutputSelector | None = None - async def preflight(self, selector: CheckerOutputSelector) -> None: + async def preflight(self, selector: GeneratedOutputSelector) -> None: """Reject malformed or unavailable action shape before protected reads.""" try: - CheckerOutputSelector.model_validate(selector) + GENERATED_OUTPUT_SELECTOR.validate_python(selector) action = ACTION_BY_ID[self._action] if ( action.availability is not ActionAvailability.ACTIVE @@ -95,7 +98,7 @@ async def preflight(self, selector: CheckerOutputSelector) -> None: except (KeyError, TypeError, ValueError) as error: raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error - async def prepare(self, selector: CheckerOutputSelector) -> None: + async def prepare(self, selector: GeneratedOutputSelector) -> None: """Lock one fixed-service grant before CHECKERS or ART feature rows.""" self.discard() if not self._session.in_transaction() or self._session.in_nested_transaction(): @@ -190,7 +193,14 @@ async def consume(self, facts: CheckerOutputCommitmentAuthorityFacts) -> None: if type(facts) is not CheckerOutputCommitmentAuthorityFacts: raise CheckerOutputUnavailable("checker_output_authority_unavailable") selector = self._require_selector() - if facts.slot_key != selector.slot_key: + try: + reservation = GENERATED_OUTPUT_RESERVATION.validate_python(facts.reservation) + slot = reservation.select(selector) + except (CheckerOutputUnavailable, ModelCallUnavailable, TypeError, ValueError) as error: + self.discard() + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + if facts.slot_key != slot.key: + self.discard() raise CheckerOutputUnavailable("checker_output_authority_unavailable") try: resource = checker_output_admission_resource(selector, facts) @@ -201,7 +211,7 @@ async def consume(self, facts: CheckerOutputCommitmentAuthorityFacts) -> None: async def consume_existing( self, - reservation: CheckerOutputReservation, + reservation: GeneratedOutputReservation, facts: CheckerOutputStoredFacts, ) -> None: """Consume exact stored ancestry selected under the prepared selector.""" @@ -213,7 +223,7 @@ async def consume_existing( raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error await self._consume(resource) - def _require_selector(self) -> CheckerOutputSelector: + def _require_selector(self) -> GeneratedOutputSelector: if self._selector is None: raise CheckerOutputUnavailable("checker_output_authority_unavailable") return self._selector @@ -233,7 +243,7 @@ def __init__(self, session) -> None: async def consume( self, - reservation: CheckerOutputReservation, + reservation: GeneratedOutputReservation, facts: CheckerOutputStoredFacts, ) -> None: """Consume only complete verified ancestry for the prepared selector.""" diff --git a/backend/app/modules/authorization/domain/audit.py b/backend/app/modules/authorization/domain/audit.py index 14a03a85f..ee711294a 100644 --- a/backend/app/modules/authorization/domain/audit.py +++ b/backend/app/modules/authorization/domain/audit.py @@ -26,6 +26,7 @@ "checker_history", "checker_model_call", "checker_model_output", + "setup_model_call", "project_model_administration", "artifact_put_attempt", "artifact_verification_job", @@ -87,6 +88,7 @@ "checker_history", "checker_model_call", "checker_model_output", + "setup_model_call", "project_model_administration", "actor_profile", "actor_authorization_context", diff --git a/backend/app/modules/authorization/domain/checker_outputs.py b/backend/app/modules/authorization/domain/checker_outputs.py index f47b94d5a..660bfa208 100644 --- a/backend/app/modules/authorization/domain/checker_outputs.py +++ b/backend/app/modules/authorization/domain/checker_outputs.py @@ -1,4 +1,4 @@ -"""Exact prepared resources for private checker-model output custody.""" +"""Exact prepared resources for private generated model-output custody.""" from __future__ import annotations @@ -6,14 +6,19 @@ from typing import Literal, Self from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator from app.core.hashing import canonical_json_hash from app.modules.authorization.catalogue import ActionId from app.modules.checkers.api.output_custody import ( - CheckerOutputReservation, - CheckerOutputSelector, + GENERATED_OUTPUT_RESERVATION, + GENERATED_OUTPUT_SELECTOR, + GeneratedOutputReservation, + GeneratedOutputSelector, + CheckerOutputUnavailable, + generated_output_subject, ) +from app.modules.model_usage.api import ModelCallUnavailable, SetupModelOutputSelector WRITE = ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE @@ -74,28 +79,41 @@ class CheckerOutputResourceContext(BaseModel): """One exact selector prepared before owner locks and closed by stored facts.""" model_config = _STRICT_FROZEN - resource_type: Literal["checker_model_output"] = "checker_model_output" + resource_type: Literal["checker_model_output", "setup_model_call"] resource_id: UUID scope_project_id: UUID action_id: ActionId - selector: CheckerOutputSelector + selector: GeneratedOutputSelector selector_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") - reservation: CheckerOutputReservation | None = None + reservation: GeneratedOutputReservation | None = None commitment: CheckerOutputCommitmentFacts | None = None stored: CheckerOutputStoredAuthorityFacts | None = None @model_validator(mode="after") def exact_selector(self) -> Self: + selector = GENERATED_OUTPUT_SELECTOR.validate_python(self.selector) if self.action_id not in CHECKER_OUTPUT_ACTIONS: raise ValueError("invalid checker output action") - if ( - self.resource_id != self.selector.model_call_attempt_id - or self.scope_project_id != self.selector.paid_slot.project_id - or self.selector_digest != checker_output_selector_digest(self.selector) - ): + resource_type, resource_id, project_id = _selector_resource(selector) + if (self.resource_type, self.resource_id, self.scope_project_id) != ( + resource_type, + resource_id, + project_id, + ) or self.selector_digest != checker_output_selector_digest(selector): raise ValueError("invalid checker output selector resource") if self.reservation is not None: - slot = self.reservation.select(self.selector) + reservation = GENERATED_OUTPUT_RESERVATION.validate_python(self.reservation) + try: + slot = reservation.select(selector) + except (CheckerOutputUnavailable, ModelCallUnavailable, ValidationError) as error: + raise ValueError("model output reservation differs from selector") from error + subject = generated_output_subject(reservation) + if ( + subject.resource_id != self.resource_id + or subject.project_id != self.scope_project_id + or _subject_resource_type(subject.resource_type) != self.resource_type + ): + raise ValueError("model output reservation resource differs") if self.commitment is not None and ( self.commitment.byte_count > slot.maximum_bytes or self.commitment.media_type != slot.media_type @@ -105,7 +123,7 @@ def exact_selector(self) -> Self: self.stored.byte_count > slot.maximum_bytes or self.stored.media_type != slot.media_type or self.stored.checker_request_digest - != canonical_json_hash(_checker_output_request_digest_facts(self.reservation, slot)) + != canonical_json_hash(subject.request_digest_facts) ): raise ValueError("stored checker output differs from its reservation") if self.commitment is not None and self.action_id is not WRITE: @@ -125,20 +143,22 @@ def exact_selector(self) -> Self: return self -def checker_output_selector_digest(selector: CheckerOutputSelector) -> str: +def checker_output_selector_digest(selector: GeneratedOutputSelector) -> str: """Hash the entire typed selector prepared before protected owner access.""" - checked = CheckerOutputSelector.model_validate(selector) + checked = GENERATED_OUTPUT_SELECTOR.validate_python(selector) return canonical_json_hash(checked.model_dump(mode="json")) def checker_output_prepare_resource( - action: ActionId, selector: CheckerOutputSelector + action: ActionId, selector: GeneratedOutputSelector ) -> CheckerOutputResourceContext: """Build the preliminary resource bound only to the caller's exact selector.""" - checked = CheckerOutputSelector.model_validate(selector) + checked = GENERATED_OUTPUT_SELECTOR.validate_python(selector) + resource_type, resource_id, project_id = _selector_resource(checked) return CheckerOutputResourceContext( - resource_id=checked.model_call_attempt_id, - scope_project_id=checked.paid_slot.project_id, + resource_type=resource_type, + resource_id=resource_id, + scope_project_id=project_id, action_id=action, selector=checked, selector_digest=checker_output_selector_digest(checked), @@ -146,12 +166,13 @@ def checker_output_prepare_resource( def checker_output_admission_resource( - selector: CheckerOutputSelector, + selector: GeneratedOutputSelector, facts: object, ) -> CheckerOutputResourceContext: """Bind prepared write authority to server-computed content facts.""" prepared = checker_output_prepare_resource(WRITE, selector) return CheckerOutputResourceContext( + resource_type=prepared.resource_type, resource_id=prepared.resource_id, scope_project_id=prepared.scope_project_id, action_id=prepared.action_id, @@ -168,13 +189,14 @@ def checker_output_admission_resource( def checker_output_stored_resource( action: ActionId, - selector: CheckerOutputSelector, - reservation: CheckerOutputReservation, + selector: GeneratedOutputSelector, + reservation: GeneratedOutputReservation, facts: object, ) -> CheckerOutputResourceContext: """Bind prepared authority to storage facts selected by ART itself.""" prepared = checker_output_prepare_resource(action, selector) return CheckerOutputResourceContext( + resource_type=prepared.resource_type, resource_id=prepared.resource_id, scope_project_id=prepared.scope_project_id, action_id=prepared.action_id, @@ -195,19 +217,22 @@ def checker_output_stored_resource( ) -def _checker_output_request_digest_facts(reservation, slot) -> dict[str, object]: - """Mirror ART's stable owner facts without importing the ART runtime.""" - selector = reservation.selector - return { - "model_call_slot_id": str(selector.model_call_slot_id), - "model_call_attempt_id": str(selector.model_call_attempt_id), - "paid_slot": selector.paid_slot.model_dump(mode="json"), - "task_id": str(selector.task_id), - "content_sha256": selector.content_sha256, - "slot_key": slot.key, - "media_type": slot.media_type, - "maximum_bytes": slot.maximum_bytes, - } +def _selector_resource( + selector: GeneratedOutputSelector, +) -> tuple[Literal["checker_model_output", "setup_model_call"], UUID, UUID]: + if isinstance(selector, SetupModelOutputSelector): + return "setup_model_call", selector.model_call_attempt_id, selector.slot_key.project_id + return ( + "checker_model_output", + selector.model_call_attempt_id, + selector.paid_slot.project_id, + ) + + +def _subject_resource_type( + value: Literal["checker_model_call", "setup_model_call"], +) -> Literal["checker_model_output", "setup_model_call"]: + return "checker_model_output" if value == "checker_model_call" else value def parse_checker_output_prepare( @@ -238,6 +263,7 @@ def checker_output_prepare_matches( return ( prepared is not None and type(resource) is CheckerOutputResourceContext + and resource.resource_type == prepared.resource_type and resource.action_id is prepared.action_id and resource.resource_id == prepared.resource_id and resource.scope_project_id == prepared.scope_project_id diff --git a/backend/app/modules/checkers/api/output_custody.py b/backend/app/modules/checkers/api/output_custody.py index 1be86b329..736cac193 100644 --- a/backend/app/modules/checkers/api/output_custody.py +++ b/backend/app/modules/checkers/api/output_custody.py @@ -5,12 +5,16 @@ from typing import Annotated, Literal, Protocol, Self from uuid import UUID -from pydantic import Field, StrictInt, model_validator +from pydantic import Field, StrictInt, TypeAdapter, model_validator + +from app.core.hashing import canonical_json_hash from app.modules.model_usage.api import ( ModelCallExecutionFacts, ModelPaidSlotKey, ModelCallEvidence, + SetupModelOutputReservation, + SetupModelOutputSelector, ) from app.modules.checkers.api.canonical_values import ( CheckerValue, @@ -165,3 +169,89 @@ async def read_response( evidence: ModelCallEvidence, ) -> bytes: """Read one authorized private response with exact byte/hash verification.""" + + +GeneratedOutputSelector = CheckerOutputSelector | SetupModelOutputSelector +GeneratedOutputReservation = CheckerOutputReservation | SetupModelOutputReservation +GENERATED_OUTPUT_SELECTOR = TypeAdapter(GeneratedOutputSelector) +GENERATED_OUTPUT_RESERVATION = TypeAdapter(GeneratedOutputReservation) + + +@dataclass(frozen=True, slots=True) +class GeneratedOutputSubject: + """Closed native lineage for the shared ART generated-output pipeline.""" + + producer_request_type: Literal["checker_output", "setup_model_output"] + project_id: UUID + task_id: UUID | None + submission_id: UUID | None + submission_version: int | None + resource_type: Literal["checker_model_call", "setup_model_call"] + resource_id: UUID + logical_role: Literal["model_request", "model_response"] + operation_identity: str + request_digest_facts: dict[str, object] + + +def generated_output_subject( + reservation: GeneratedOutputReservation, +) -> GeneratedOutputSubject: + """Preserve checker identities and add one exact setup-effect subject.""" + reservation = GENERATED_OUTPUT_RESERVATION.validate_python(reservation) + selector, slot = reservation.selector, reservation.slot + if isinstance(reservation, SetupModelOutputReservation): + key = selector.slot_key + return GeneratedOutputSubject( + "setup_model_output", + key.project_id, + None, + None, + None, + "setup_model_call", + selector.model_call_attempt_id, + slot.key, + canonical_json_hash( + { + "request_type": "setup_model_output", + "model_call_attempt_id": str(selector.model_call_attempt_id), + "logical_role": slot.key, + } + ), + { + "slot_key": key.model_dump(mode="json"), + "model_call_slot_id": str(selector.model_call_slot_id), + "model_call_attempt_id": str(selector.model_call_attempt_id), + "content_sha256": selector.content_sha256, + "role": slot.key, + "media_type": slot.media_type, + "maximum_bytes": slot.maximum_bytes, + }, + ) + paid = selector.paid_slot + return GeneratedOutputSubject( + "checker_output", + paid.project_id, + selector.task_id, + paid.submission_id, + paid.submission_version, + "checker_model_call", + selector.model_call_attempt_id, + slot.key, + canonical_json_hash( + { + "request_type": "checker_output", + "model_call_attempt_id": str(selector.model_call_attempt_id), + "logical_role": slot.key, + } + ), + { + "model_call_slot_id": str(selector.model_call_slot_id), + "model_call_attempt_id": str(selector.model_call_attempt_id), + "paid_slot": paid.model_dump(mode="json"), + "task_id": str(selector.task_id), + "content_sha256": selector.content_sha256, + "slot_key": slot.key, + "media_type": slot.media_type, + "maximum_bytes": slot.maximum_bytes, + }, + ) diff --git a/backend/app/modules/model_usage/api.py b/backend/app/modules/model_usage/api.py index 319174191..33d6024b7 100644 --- a/backend/app/modules/model_usage/api.py +++ b/backend/app/modules/model_usage/api.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import AsyncIterable +from dataclasses import dataclass from datetime import datetime from typing import Annotated, Literal, Protocol, Self from uuid import RFC_4122, UUID @@ -559,6 +561,124 @@ async def resolve_evidence(self, selector: ModelEvidenceSelector) -> ModelEviden ... +class SetupModelOutputSelector(ModelCallValue): + """Select one private setup request or response by its exact paid effect.""" + + slot_key: SetupModelUsageSlotKey + model_call_slot_id: UUID + model_call_attempt_id: UUID + role: Literal["model_request", "model_response"] + content_sha256: Sha256 + + _decode_public_uuids = field_validator( + "model_call_slot_id", "model_call_attempt_id", mode="before" + )(_public_uuid) + + +class SetupModelOutputSlot(ModelCallValue): + """One bounded canonical JSON role issued by the setup effect owner.""" + + key: Literal["model_request", "model_response"] + media_type: Literal["application/json"] = "application/json" + maximum_bytes: StrictInt = Field(ge=1, le=1_048_576) + + +class SetupModelOutputReservation(ModelCallValue): + """Detached setup owner facts, never fresh dispatch authority.""" + + selector: SetupModelOutputSelector + slot: SetupModelOutputSlot + + @model_validator(mode="after") + def exact_role(self) -> Self: + if self.slot.key != self.selector.role: + raise ValueError("setup model output role differs from reservation") + return self + + def select(self, selector: SetupModelOutputSelector) -> SetupModelOutputSlot: + current = SetupModelOutputReservation.model_validate(self) + if current.selector != SetupModelOutputSelector.model_validate(selector): + raise ModelCallUnavailable("setup_model_output_reservation_unavailable") + return current.slot + + +class SetupModelOutputReservationPort(Protocol): + """Resolve current request or retained paid-effect response ART custody.""" + + async def resolve( + self, selector: SetupModelOutputSelector + ) -> SetupModelOutputReservation: + """Return exact bounds after the role-specific owner checks.""" + ... + + +@dataclass(frozen=True, slots=True) +class SetupModelOutputArtifactRequest: + """Bounded setup bytes admitted through the shared generated-output path.""" + + selector: SetupModelOutputSelector + byte_source: AsyncIterable[bytes] + + +@dataclass(frozen=True, slots=True) +class SetupModelOutputBindingRequest: + """Bind one independently verified setup output to its paid effect.""" + + selector: SetupModelOutputSelector + put_attempt_id: UUID + verification_receipt_id: UUID + + +@dataclass(frozen=True, slots=True) +class SetupModelOutputArtifactResult: + put_attempt_id: UUID + status: str + content_id: UUID | None + verification_receipt_id: UUID | None + replayed: bool + + +@dataclass(frozen=True, slots=True) +class SetupModelOutputBindingResult: + binding_id: UUID + content_id: UUID + put_attempt_id: UUID + verification_receipt_id: UUID + replayed: bool + + +class SetupModelOutputBindingPort(Protocol): + """Publish exact verified setup bytes under the paid effect owner.""" + + async def bind_setup_output( + self, request: SetupModelOutputBindingRequest + ) -> SetupModelOutputBindingResult: + """Bind one setup request or response in the caller transaction.""" + ... + + +class SetupModelArtifactOutputPort(Protocol): + """Store and recover bounded setup output through shared ART custody.""" + + async def store_setup_output( + self, request: SetupModelOutputArtifactRequest + ) -> SetupModelOutputArtifactResult: + """Store one setup request or response.""" + ... + + async def recover_setup_output( + self, selector: SetupModelOutputSelector + ) -> SetupModelOutputArtifactResult | None: + """Recover a committed setup output without regenerating bytes.""" + ... + + async def read_setup_response( + self, selector: SetupModelOutputSelector, evidence: ModelCallEvidence + ) -> bytes: + """Read exact independently verified response bytes.""" + ... + + class ModelRecoverySourcePort(Protocol): """Read retained recovery lineage without granting fresh owner authority.""" @@ -1054,6 +1174,16 @@ def model_action_digest(action: str, facts: object) -> str: "ProjectModelProfileSpec", "PreparedSetupModelUsageEffect", "SetupModelDispatchReservation", + "SetupModelOutputReservation", + "SetupModelOutputReservationPort", + "SetupModelArtifactOutputPort", + "SetupModelOutputArtifactRequest", + "SetupModelOutputArtifactResult", + "SetupModelOutputBindingPort", + "SetupModelOutputBindingRequest", + "SetupModelOutputBindingResult", + "SetupModelOutputSelector", + "SetupModelOutputSlot", "SetupModelEffectAuthorityFacts", "SetupModelUsageAuthorityPort", "SetupModelUsageEvidencePort", diff --git a/backend/app/modules/model_usage/output_custody.py b/backend/app/modules/model_usage/output_custody.py new file mode 100644 index 000000000..890a5f78e --- /dev/null +++ b/backend/app/modules/model_usage/output_custody.py @@ -0,0 +1,111 @@ +"""MODEL-USAGE-owned setup output custody for the shared ART pipeline.""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.model_usage.api import ( + ModelProfileSelector, + ModelCallUnavailable, + SetupModelOutputReservation, + SetupModelOutputSelector, + SetupModelOutputSlot, + SetupModelUsageOwnerPort, +) +from app.modules.model_usage.repository import ( + ModelCallRepository, + require_model_call_transaction, +) + + +class SetupModelOutputReservations: + """Requests require current PROJECTS custody; responses retain paid effects.""" + + def __init__( + self, + session: AsyncSession, + *, + owner: SetupModelUsageOwnerPort, + ) -> None: + self._repo = ModelCallRepository(session) + self._owner = owner + self._session = session + + async def resolve( + self, selector: SetupModelOutputSelector + ) -> SetupModelOutputReservation: + """Resolve one exact setup role without MODEL-to-PROJECTS lock backedges.""" + require_model_call_transaction(self._session) + selector = SetupModelOutputSelector.model_validate(selector) + key = selector.slot_key + if selector.role == "model_request": + owner_profile = await self._owner.require_setup_current(key) + if owner_profile.selector != ModelProfileSelector( + profile_id=key.profile_id, + profile_version=key.profile_version, + profile_digest=key.profile_digest, + ): + raise ModelCallUnavailable("setup_model_output_owner_unavailable") + + await self._repo.lock_setup_envelope_scope(key) + envelope = await self._repo.setup_envelope(key, lock=True) + await self._repo.lock_setup_slot_scope(key) + slot = await self._repo.setup_slot(key, lock=True) + effect = await self._repo.attempt(selector.model_call_attempt_id, lock=True) + profile = await self._repo.profile_exact( + key.profile_id, key.profile_version, key.profile_digest + ) + if ( + envelope is None + or slot is None + or effect is None + or profile is None + or slot.envelope_id != envelope.id + or slot.id != str(selector.model_call_slot_id) + or effect.setup_slot_id != slot.id + or profile.project_id != str(key.project_id) + or ( + slot.compilation_attempt_id, + slot.project_id, + slot.guide_id, + slot.source_snapshot_id, + slot.setup_run_id, + slot.setup_generation, + slot.runtime_configuration_hash, + slot.profile_id, + slot.profile_version, + slot.profile_digest, + slot.phase, + slot.call_index, + ) + != ( + str(key.compilation_attempt_id), + str(key.project_id), + str(key.guide_id), + str(key.source_snapshot_id), + str(key.setup_run_id), + key.setup_generation, + key.runtime_configuration_hash, + str(key.profile_id), + key.profile_version, + key.profile_digest, + key.phase, + key.call_index, + ) + ): + raise ModelCallUnavailable("setup_model_output_unavailable") + if selector.role == "model_request": + if effect.request_sha256 != selector.content_sha256: + raise ModelCallUnavailable("setup_model_output_request_conflict") + maximum = profile.maximum_request_bytes + else: + if effect.state not in {"dispatching", "uncertain", "completed", "known_failed"}: + raise ModelCallUnavailable("setup_model_output_effect_unavailable") + if effect.response_content_sha256 not in {None, selector.content_sha256}: + raise ModelCallUnavailable("setup_model_output_response_conflict") + maximum = profile.maximum_response_bytes + return SetupModelOutputReservation( + selector=selector, + slot=SetupModelOutputSlot(key=selector.role, maximum_bytes=maximum), + ) + + +__all__ = ("SetupModelOutputReservations",) diff --git a/backend/tests/authorization/checker_models/test_contracts.py b/backend/tests/authorization/checker_models/test_contracts.py index 941145eb6..d131314e0 100644 --- a/backend/tests/authorization/checker_models/test_contracts.py +++ b/backend/tests/authorization/checker_models/test_contracts.py @@ -42,11 +42,16 @@ ModelCallExecutionFacts, ModelCallUnavailable, ModelPaidSlotKey, + SetupModelOutputReservation, + SetupModelOutputSelector, + SetupModelOutputSlot, + SetupModelUsageSlotKey, ) from app.modules.checkers.api.output_custody import ( CheckerOutputReservation, CheckerOutputSelector, CheckerOutputSlot, + generated_output_subject, ) @@ -150,12 +155,91 @@ def test_output_prepare_accepts_its_json_packet_without_coercing_integers(action parse_checker_output_prepare(action, packet, RuntimeError) assert isinstance(rejected.value.__cause__, ValidationError) assert any( - error["loc"] == ("selector", "paid_slot", "submission_version") + error["loc"][-2:] == ("paid_slot", "submission_version") and error["type"] == "int_type" for error in rejected.value.__cause__.errors() ) +def setup_selector(*, role="model_request") -> SetupModelOutputSelector: + key = SetupModelUsageSlotKey( + compilation_attempt_id=new_record_id(), + project_id=new_record_id(), + guide_id=new_record_id(), + source_snapshot_id=new_record_id(), + setup_run_id=new_record_id(), + setup_generation=1, + runtime_configuration_hash=sha("runtime configuration"), + profile_id=new_record_id(), + profile_version=1, + profile_digest=sha("setup profile"), + phase="draft", + call_index=0, + ) + return SetupModelOutputSelector( + slot_key=key, + model_call_slot_id=new_record_id(), + model_call_attempt_id=new_record_id(), + role=role, + content_sha256=sha(role), + ) + + +@pytest.mark.parametrize("action", [WRITE, BIND]) +def test_setup_output_resource_is_closed_to_exact_effect_and_project(action) -> None: + selected = setup_selector() + reservation = SetupModelOutputReservation( + selector=selected, + slot=SetupModelOutputSlot(key="model_request", maximum_bytes=2048), + ) + prepared = checker_output_prepare_resource(action, selected) + + assert prepared.resource_type == "setup_model_call" + assert prepared.resource_id == selected.model_call_attempt_id + assert prepared.scope_project_id == selected.slot_key.project_id + subject = generated_output_subject(reservation) + assert subject.operation_identity == canonical_json_hash( + { + "request_type": "setup_model_output", + "model_call_attempt_id": str(selected.model_call_attempt_id), + "logical_role": "model_request", + } + ) + assert subject.request_digest_facts == { + "slot_key": selected.slot_key.model_dump(mode="json"), + "model_call_slot_id": str(selected.model_call_slot_id), + "model_call_attempt_id": str(selected.model_call_attempt_id), + "content_sha256": selected.content_sha256, + "role": "model_request", + "media_type": "application/json", + "maximum_bytes": 2048, + } + assert parse_checker_output_prepare( + action, prepared.model_dump(mode="json"), RuntimeError + ) == prepared + + substituted = selected.model_copy( + update={"model_call_attempt_id": new_record_id()} + ) + with pytest.raises(ValueError, match="differs from selector"): + checker_output_stored_resource( + action, + substituted, + reservation, + SimpleNamespace( + put_attempt_id=new_record_id(), + checker_request_digest=sha("custody"), + sha256=substituted.content_sha256, + byte_count=100, + media_type="application/json", + status="object_confirmed", + content_id=None, + replica_id=None, + verification_receipt_id=None, + ), + ) + + def test_output_final_facts_cannot_substitute_selector_or_unverified_binding() -> None: current = execution() slot = paid_slot(current) diff --git a/backend/tests/checkers_model_calls/test_setup_output_custody.py b/backend/tests/checkers_model_calls/test_setup_output_custody.py new file mode 100644 index 000000000..94ee5d651 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_setup_output_custody.py @@ -0,0 +1,176 @@ +"""Owner-order and exact-identity proofs for setup ART reservations.""" + +from types import SimpleNamespace + +import pytest + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.model_usage.api import ( + ModelCallUnavailable, + ModelProfileSelector, + SetupModelOutputSelector, + SetupModelOwnerProfile, + SetupModelUsageSlotKey, +) +from app.modules.model_usage.output_custody import SetupModelOutputReservations + + +def sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +def selector(*, role: str = "model_request") -> SetupModelOutputSelector: + key = SetupModelUsageSlotKey( + compilation_attempt_id=new_record_id(), + project_id=new_record_id(), + guide_id=new_record_id(), + source_snapshot_id=new_record_id(), + setup_run_id=new_record_id(), + setup_generation=2, + runtime_configuration_hash=sha("runtime"), + profile_id=new_record_id(), + profile_version=3, + profile_digest=sha("profile"), + phase="review", + call_index=1, + ) + return SetupModelOutputSelector( + slot_key=key, + model_call_slot_id=new_record_id(), + model_call_attempt_id=new_record_id(), + role=role, + content_sha256=sha(role), + ) + + +class Owner: + def __init__(self, calls: list[str]) -> None: + self.calls = calls + + async def require_setup_current(self, key): + self.calls.append("owner") + return SetupModelOwnerProfile( + selector=ModelProfileSelector( + profile_id=key.profile_id, + profile_version=key.profile_version, + profile_digest=key.profile_digest, + ), + provider="provider", + api="api.v1", + model_snapshot="model-2026-10-01", + ) + + +class Repository: + def __init__(self, selected: SetupModelOutputSelector, calls: list[str]) -> None: + self.selected = selected + self.calls = calls + key = selected.slot_key + self.envelope = SimpleNamespace(id=str(new_record_id())) + self.slot = SimpleNamespace( + id=str(selected.model_call_slot_id), + envelope_id=self.envelope.id, + compilation_attempt_id=str(key.compilation_attempt_id), + project_id=str(key.project_id), + guide_id=str(key.guide_id), + source_snapshot_id=str(key.source_snapshot_id), + setup_run_id=str(key.setup_run_id), + setup_generation=key.setup_generation, + runtime_configuration_hash=key.runtime_configuration_hash, + profile_id=str(key.profile_id), + profile_version=key.profile_version, + profile_digest=key.profile_digest, + phase=key.phase, + call_index=key.call_index, + ) + self.effect = SimpleNamespace( + setup_slot_id=self.slot.id, + request_sha256=selected.content_sha256, + response_content_sha256=None, + state="dispatching", + ) + self.profile = SimpleNamespace( + project_id=str(key.project_id), + maximum_request_bytes=10_000, + maximum_response_bytes=20_000, + ) + + async def lock_setup_envelope_scope(self, _key): + self.calls.append("envelope-scope") + + async def setup_envelope(self, _key, *, lock): + assert lock + self.calls.append("envelope") + return self.envelope + + async def lock_setup_slot_scope(self, _key): + self.calls.append("slot-scope") + + async def setup_slot(self, _key, *, lock): + assert lock + self.calls.append("slot") + return self.slot + + async def attempt(self, _attempt_id, *, lock): + assert lock + self.calls.append("effect") + return self.effect + + async def profile_exact(self, *_identity): + self.calls.append("profile") + return self.profile + + +async def resolve(monkeypatch, selected): + calls: list[str] = [] + service = SetupModelOutputReservations(object(), owner=Owner(calls)) + service._repo = Repository(selected, calls) + monkeypatch.setattr( + "app.modules.model_usage.output_custody.require_model_call_transaction", + lambda _session: None, + ) + return await service.resolve(selected), calls + + +@pytest.mark.asyncio +async def test_request_checks_owner_before_canonical_model_lock_order(monkeypatch) -> None: + reservation, calls = await resolve(monkeypatch, selector()) + + assert reservation.slot.maximum_bytes == 10_000 + assert calls == [ + "owner", + "envelope-scope", + "envelope", + "slot-scope", + "slot", + "effect", + "profile", + ] + + +@pytest.mark.asyncio +async def test_response_uses_retained_effect_without_projects_callback(monkeypatch) -> None: + selected = selector(role="model_response") + reservation, calls = await resolve(monkeypatch, selected) + + assert reservation.slot.maximum_bytes == 20_000 + assert "owner" not in calls + assert calls[:2] == ["envelope-scope", "envelope"] + + +@pytest.mark.asyncio +async def test_substituted_response_hash_is_rejected(monkeypatch) -> None: + selected = selector(role="model_response") + calls: list[str] = [] + service = SetupModelOutputReservations(object(), owner=Owner(calls)) + repository = Repository(selected, calls) + repository.effect.response_content_sha256 = sha("different-response") + service._repo = repository + monkeypatch.setattr( + "app.modules.model_usage.output_custody.require_model_call_transaction", + lambda _session: None, + ) + + with pytest.raises(ModelCallUnavailable, match="response_conflict"): + await service.resolve(selected) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d4100b0b5..757ad799a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,7 +23,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "72fd1f02469628d883a40fd63aa0890709b29e39185d9c96ce64164aeb5a9412" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "ddd9fb20122bbc3e34fb7545e4385b280bcd348c8dc8e8b6823597171af11ebb" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/projects/guide_compilation/test_runtime_configuration_postgresql.py b/backend/tests/projects/guide_compilation/test_runtime_configuration_postgresql.py index dbfde6143..25b8d3e3c 100644 --- a/backend/tests/projects/guide_compilation/test_runtime_configuration_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_runtime_configuration_postgresql.py @@ -218,6 +218,42 @@ async def test_sql_requires_all_none_or_all_concrete_inference_identity( await engine.dispose() +async def test_sql_model_profile_selector_is_all_null_or_complete( + clean_postgres_database, +): + """Partial selectors fail closed even when a missing JSON key yields SQL NULL.""" + values = await seed_database(clean_postgres_database) + null_selector = runtime_configuration().model_dump(mode="json") + complete = null_selector | { + "model_profile_id": str(new_record_id()), + "model_profile_version": 1, + "model_profile_digest": canonical_json_hash({"profile": "runtime-test"}), + } + invalid = ( + {key: value for key, value in complete.items() if key != "model_profile_id"}, + {key: value for key, value in complete.items() if key != "model_profile_digest"}, + ) + engine = create_async_engine(clean_postgres_database) + try: + for configuration in invalid: + async with engine.begin() as connection: + with pytest.raises( + DBAPIError, match="compilation runtime configuration is invalid" + ) as error: + await connection.execute( + insert(ProjectGuideCompilationAttempt).values( + **_row(values, configuration) + ) + ) + assert error.value.orig.sqlstate == "23514" + async with engine.begin() as connection: + await connection.execute( + insert(ProjectGuideCompilationAttempt).values(**_row(values, complete)) + ) + finally: + await engine.dispose() + + async def test_sql_runtime_configuration_and_hash_are_immutable(clean_postgres_database): values = await seed_database(clean_postgres_database) configuration = runtime_configuration().model_dump(mode="json") From dc2d4d21d6c312d287d1ef74bdfad7f3972dc41e Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 20:07:50 +0100 Subject: [PATCH 017/115] Permit setup model artifact bindings --- backend/alembic/versions/0061_model_usage.py | 3 ++- backend/tests/conftest.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/alembic/versions/0061_model_usage.py b/backend/alembic/versions/0061_model_usage.py index 719df7e7b..bad0ae333 100644 --- a/backend/alembic/versions/0061_model_usage.py +++ b/backend/alembic/versions/0061_model_usage.py @@ -772,7 +772,8 @@ def _upgrade_art_custody() -> None: CASE WHEN NEW.resource_type='setup_model_call' THEN 'setup_model_output' ELSE 'checker_output' END) OR attempt.producer_type <> 'service_identity' - OR attempt.submission_id IS NULL OR attempt.submission_version IS NULL + OR (NEW.resource_type <> 'setup_model_call' + AND (attempt.submission_id IS NULL OR attempt.submission_version IS NULL)) OR attempt.checker_request_digest IS NULL OR attempt.status <> 'object_confirmed' OR attempt.replica_id IS DISTINCT FROM replica.id diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 757ad799a..357657c9e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,7 +23,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "ddd9fb20122bbc3e34fb7545e4385b280bcd348c8dc8e8b6823597171af11ebb" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "197e65fed2bcba5cc7888fe6278b6f32ea9ff817329859036713bbb9d41c8f59" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", From b725ef0282dc80cbf06b7c4b7b869c7885f9edc6 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 20:33:12 +0100 Subject: [PATCH 018/115] feat(model-usage): meter setup agent dispatches --- backend/app/adapters/auth/__init__.py | 158 ++-- backend/app/adapters/checkers/model_proxy.py | 46 +- .../checkers/model_proxy_transport.py | 42 +- .../app/adapters/checkers/model_runtime.py | 43 +- .../adapters/project_agents/omnicore_agent.py | 115 ++- .../project_agents/openai_agent_sdk.py | 60 +- .../project_agents/setup_model_handler.py | 861 ++++++++++++++++++ .../project_agents/setup_model_proxy.py | 357 ++++++++ backend/app/adapters/projects/__init__.py | 84 +- backend/app/interfaces/setup_model_proxy.py | 324 +++++++ backend/app/modules/model_usage/api.py | 50 +- backend/app/modules/model_usage/profiles.py | 25 + backend/app/modules/model_usage/setup.py | 37 +- .../modules/projects/api/guide_documents.py | 5 +- .../projects/guide_compilation/live.py | 61 +- .../projects/guide_compilation/model_usage.py | 2 +- .../guide_compilation/orchestrator.py | 126 ++- backend/app/workers/project_setup.py | 43 +- .../external/test_harnessy_proof_checker.py | 7 +- .../test_model_shared_setup_postgresql.py | 70 +- .../test_proxy_orchestration.py | 35 +- .../test_setup_model_handler.py | 462 ++++++++++ .../test_setup_model_proxy_transport.py | 154 ++++ .../guide_compilation/proposals/pg_support.py | 7 + .../guide_compilation/test_live_worker.py | 12 +- backend/tests/test_agent_runtime.py | 130 ++- docker-compose.agent-loop.yml | 12 +- docker-compose.yml | 6 +- scripts/test_local_guide_runtime.py | 20 +- 29 files changed, 3041 insertions(+), 313 deletions(-) create mode 100644 backend/app/adapters/project_agents/setup_model_handler.py create mode 100644 backend/app/adapters/project_agents/setup_model_proxy.py create mode 100644 backend/app/interfaces/setup_model_proxy.py create mode 100644 backend/tests/checkers_model_calls/test_setup_model_handler.py create mode 100644 backend/tests/checkers_model_calls/test_setup_model_proxy_transport.py diff --git a/backend/app/adapters/auth/__init__.py b/backend/app/adapters/auth/__init__.py index f3aec2df4..293b90a87 100644 --- a/backend/app/adapters/auth/__init__.py +++ b/backend/app/adapters/auth/__init__.py @@ -1,60 +1,68 @@ """Authorization application adapters and same-owner composition.""" import json - -from app.modules.authorization.domain.post_submit_routing import ( - AutomatedAcceptanceConsequence, - CheckerRemediationConsequence, CheckerExhaustionConsequence, - HumanAdmissionConsequence, - PostSubmitRoutingResourceContext, -) -from app.modules.authorization.post_submit_routing_authorization import ( - PostSubmitRoutingAuthorization, -) -from app.modules.tasks.api.routing_outcome import RoutingAuthorityFacts -from app.modules.authorization.runtime import PreparedAuthorizationHandleInvalid - -from app.adapters.auth.assignment_invalidation_publication import assignment_invalidation_publication -from app.modules.projects.api.guide_activation import GuideActivationAuthorizationPort from contextlib import asynccontextmanager from uuid import UUID, uuid5 -from app.modules.actors.api import ServiceIdentity -from app.modules.authorization.api import ( - ActorIdentityFacts, ActorKind, AuthorizationDenied, ProjectGuideCompilationAuthorizationPort, -) -from app.modules.authorization.api.guide_proposal_review import GuideProposalAuthorizationPort -from app.modules.authorization.api.post_policy import PostPolicyAuthorizationPort -from app.modules.authorization.api.outbox_dispatch import OutboxDispatchAuthorizationPort -from app.modules.authorization.guide_compilation import ProjectGuideCompilationAuthorizationAdapter -from app.modules.authorization.prepared import fixed_service_prepared_authorization -from app.modules.authorization.runtime import PreparedAuthorizationUnsupported from sqlalchemy.ext.asyncio import AsyncSession -from app.adapters.auth.contribution_policies import ContributionPolicyAuthorization -from app.modules.authorization.contribution_policy_authorization import ( - ContributionPolicyAuthorizationAdapter, -) - from app.adapters.auth.adapter_bindings import CompensationAdapterBindingAuthorization +from app.adapters.auth.assignment_invalidation_publication import ( + assignment_invalidation_publication, +) +from app.adapters.auth.contribution_policies import ContributionPolicyAuthorization +from app.modules.actors.api import ServiceIdentity from app.modules.authorization.adapter_binding_authorization import ( AdapterBindingAuthorizationAdapter, ) -from app.modules.authorization.project_setup_finalization import SetupFinalizationAuthorization -from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.api import ( + ActorIdentityFacts, + ActorKind, + AuthorizationDenied, + ProjectGuideCompilationAuthorizationPort, +) +from app.modules.authorization.api.guide_proposal_review import GuideProposalAuthorizationPort +from app.modules.authorization.api.outbox_dispatch import OutboxDispatchAuthorizationPort +from app.modules.authorization.api.post_policy import PostPolicyAuthorizationPort from app.modules.authorization.artifact_operator_authorization import ( ArtifactOperatorReadAuthorization, ArtifactOperatorRetryAuthorization, ) -from app.modules.authorization.prepared import PreparedAuthorizationService -from app.modules.authorization.repository import AdminAuthorizationRepository -from app.modules.authorization.runtime import AuthorizationContext, ServiceAuthorizationContext -from app.modules.authorization.task_authorization import PreparedTaskAuthorization -from app.modules.authorization.task_import_sources import PreparedTaskImportSourceAuthorization +from app.modules.authorization.contribution_policy_authorization import ( + ContributionPolicyAuthorizationAdapter, +) +from app.modules.authorization.domain.post_submit_routing import ( + AutomatedAcceptanceConsequence, + CheckerExhaustionConsequence, + CheckerRemediationConsequence, + HumanAdmissionConsequence, + PostSubmitRoutingResourceContext, +) +from app.modules.authorization.guide_compilation import ProjectGuideCompilationAuthorizationAdapter from app.modules.authorization.guide_compilation_projections import ( ArtifactPolicyProjectionAuthorization, GuideSufficiencyProjectionAuthorization, ) +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.post_submit_routing_authorization import ( + PostSubmitRoutingAuthorization, +) +from app.modules.authorization.prepared import ( + PreparedAuthorizationService, + fixed_service_prepared_authorization, +) +from app.modules.authorization.project_setup_finalization import SetupFinalizationAuthorization +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.runtime import ( + AuthorizationContext, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationUnsupported, + ServiceAuthorizationContext, +) +from app.modules.authorization.task_authorization import PreparedTaskAuthorization +from app.modules.authorization.task_import_sources import PreparedTaskImportSourceAuthorization +from app.modules.projects.api.guide_activation import GuideActivationAuthorizationPort +from app.modules.tasks.api.routing_outcome import RoutingAuthorityFacts def artifact_operator_read_authorization( @@ -96,7 +104,9 @@ def task_import_source_authorization( def checker_retry_authorization(session: AsyncSession, context: AuthorizationContext): """Compose one hidden retry through canonical prepared Operator authority.""" - from app.modules.authorization.checker_retry_authorization import PreparedCheckerRetryAuthorization + from app.modules.authorization.checker_retry_authorization import ( + PreparedCheckerRetryAuthorization, + ) return PreparedCheckerRetryAuthorization(session, context) @@ -145,7 +155,12 @@ def checker_model_authorization(session: AsyncSession, context: AuthorizationCon if isinstance(context, ServiceAuthorizationContext): from app.modules.authorization.prepared import fixed_service_context_revalidator - revalidate = fixed_service_context_revalidator(repository, ServiceIdentity.CHECKER_POST_SUBMIT) + if context.service_identity not in { + ServiceIdentity.CHECKER_POST_SUBMIT, + ServiceIdentity.PROJECT_SETUP, + }: + raise TypeError("model usage service identity is unsupported") + revalidate = fixed_service_context_revalidator(repository, context.service_identity) kernel = AuthorizationService( session, context, admin_repository=repository, revalidate_service=revalidate, ) @@ -163,16 +178,33 @@ async def checker_model_service_authorization(session: AsyncSession, request_id: return checker_model_authorization(session, context) +async def setup_model_service_authorization(session: AsyncSession, request_id: UUID): + """Resolve the fixed PROJECT_SETUP principal for one setup model effect.""" + from app.modules.authorization.prepared import fixed_service_authorization_context + + context = await fixed_service_authorization_context( + session, + ServiceIdentity.PROJECT_SETUP, + request_id, + uuid5(request_id, "setup-model-authority"), + ) + return checker_model_authorization(session, context) + + def checker_output_write_authorization(session: AsyncSession): """Compose private model-evidence admission and recovery authority.""" - from app.modules.authorization.checker_output_authorization import PreparedCheckerOutputWriteAuthorization + from app.modules.authorization.checker_output_authorization import ( + PreparedCheckerOutputWriteAuthorization, + ) return PreparedCheckerOutputWriteAuthorization(session) def checker_output_binding_authorization(session: AsyncSession): """Compose verified call-evidence publication authority.""" - from app.modules.authorization.checker_output_authorization import PreparedCheckerOutputBindingAuthorization + from app.modules.authorization.checker_output_authorization import ( + PreparedCheckerOutputBindingAuthorization, + ) return PreparedCheckerOutputBindingAuthorization(session) @@ -224,19 +256,19 @@ def contribution_read_authorization(session: AsyncSession, context: Authorizatio __all__ = ( + "CompensationAdapterBindingAuthorization", + "ContributionPolicyAuthorization", + "artifact_policy_projection_authorization", "assignment_invalidation_publication", - "task_authorization", - "task_import_source_authorization", "checker_registry_authorization", - "guide_compilation_request_authority", - "guide_compilation_execution_authority", - "ContributionPolicyAuthorization", - "contribution_policy_authorization", - "CompensationAdapterBindingAuthorization", "compensation_adapter_binding_authorization", - "artifact_policy_projection_authorization", + "contribution_policy_authorization", + "guide_compilation_execution_authority", + "guide_compilation_request_authority", "guide_sufficiency_projection_authorization", "setup_finalization_authorization", + "task_authorization", + "task_import_source_authorization", ) @@ -279,7 +311,9 @@ def guide_proposal_authorization( session: AsyncSession, context: AuthorizationContext, ) -> GuideProposalAuthorizationPort: """Bind the public proposal authority port to the canonical request adapter.""" - from app.modules.authorization.guide_proposal_authorization import GuideProposalAuthorizationAdapter + from app.modules.authorization.guide_proposal_authorization import ( + GuideProposalAuthorizationAdapter, + ) return GuideProposalAuthorizationAdapter(session, context) @@ -325,35 +359,45 @@ def guide_activation_authorization( session: AsyncSession, context: AuthorizationContext, ) -> GuideActivationAuthorizationPort: """Compose live manager authority for the sole complete-guide operation.""" - from app.modules.authorization.guide_activation_authorization import GuideActivationAuthorizationAdapter + from app.modules.authorization.guide_activation_authorization import ( + GuideActivationAuthorizationAdapter, + ) return GuideActivationAuthorizationAdapter(session, context) def outbox_dispatch_authorization(session: AsyncSession) -> "OutboxDispatchAuthorizationPort": """Compose the one fixed dispatcher through canonical AUTH/PREP.""" - from app.modules.authorization.outbox_dispatch_authorization import OutboxDispatchAuthorizationAdapter + from app.modules.authorization.outbox_dispatch_authorization import ( + OutboxDispatchAuthorizationAdapter, + ) return OutboxDispatchAuthorizationAdapter(session) def assignment_invalidation_authorization(session: AsyncSession): """Compose the exact reconciler through canonical fixed-service PREP.""" - from app.modules.authorization.assignment_invalidation_authorization import AssignmentInvalidationAuthorizationAdapter + from app.modules.authorization.assignment_invalidation_authorization import ( + AssignmentInvalidationAuthorizationAdapter, + ) return AssignmentInvalidationAuthorizationAdapter(session) def revision_expiry_authorization(session: AsyncSession): """Bind TASK's deadline effect to canonical fixed-service AUTH.""" - from app.modules.authorization.revision_expiry_authorization import RevisionExpiryAuthorizationAdapter + from app.modules.authorization.revision_expiry_authorization import ( + RevisionExpiryAuthorizationAdapter, + ) return RevisionExpiryAuthorizationAdapter(session) def revision_recovery_authorization(session: AsyncSession): """Bind retained-window recovery to canonical fixed-service AUTH.""" - from app.modules.authorization.revision_recovery_authorization import RevisionRecoveryAuthorizationAdapter + from app.modules.authorization.revision_recovery_authorization import ( + RevisionRecoveryAuthorizationAdapter, + ) return RevisionRecoveryAuthorizationAdapter(session) @@ -446,7 +490,9 @@ def selected_pre_submit_authority(session): def post_submit_materialization_authority(session): """Compose the separate fixed ART materializer principal.""" - from app.modules.authorization.post_submit_authorization import PostSubmitMaterializationAuthorization + from app.modules.authorization.post_submit_authorization import ( + PostSubmitMaterializationAuthorization, + ) return PostSubmitMaterializationAuthorization(session) diff --git a/backend/app/adapters/checkers/model_proxy.py b/backend/app/adapters/checkers/model_proxy.py index 25609bc08..6d00b783d 100644 --- a/backend/app/adapters/checkers/model_proxy.py +++ b/backend/app/adapters/checkers/model_proxy.py @@ -1,14 +1,14 @@ """Durable model-call orchestration for project-owned checker images.""" import asyncio -from contextlib import AbstractAsyncContextManager -from collections.abc import Callable -from dataclasses import asdict import hashlib import json +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager +from dataclasses import asdict from uuid import uuid4 -from sqlalchemy import select, func +from sqlalchemy import func, select from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id @@ -20,25 +20,25 @@ CheckerModelRequest, CheckerModelResponseReference, ) -from app.interfaces.checker_model_proxy import ModelProxyRequest, ModelProxyReply +from app.interfaces.checker_model_proxy import ModelProxyReply, ModelProxyRequest +from app.modules.checkers.api.output_custody import ( + CheckerArtifactOutputPort, + CheckerOutputArtifactRequest, + CheckerOutputBindingRequest, + CheckerOutputSelector, + CheckerOutputUnavailable, +) from app.modules.model_usage.api import ( + KnownNoEffectProof, + ModelCallBudgetUnavailable, ModelCallEffectAuthorityFacts, ModelCallEvidence, - ModelCallBudgetUnavailable, - ModelCallUsage, ModelCallUnavailable, + ModelCallUsage, ModelProfileSelector, PreparedModelCallAttempt, - KnownNoEffectProof, ProjectModelProfile, ) -from app.modules.checkers.api.output_custody import ( - CheckerArtifactOutputPort, - CheckerOutputArtifactRequest, - CheckerOutputBindingRequest, - CheckerOutputSelector, - CheckerOutputUnavailable, -) def _canonical_bytes(value: object) -> bytes: @@ -324,6 +324,10 @@ async def _complete( "observation": asdict(observation), } ) + output = observation.output_text + oversized_output = output is not None and ( + len(output.encode("utf-8")) > profile.spec.maximum_output_bytes + ) if len(data) > profile.spec.maximum_response_bytes: return _unavailable("model_response_too_large", call_index) evidence = await self._store(facts, effect, "model_response", data) @@ -334,8 +338,10 @@ async def _complete( output_tokens=raw_usage.output_tokens, reasoning_tokens=raw_usage.reasoning_tokens, ) - usable = observation.status is CheckerModelProviderStatus.COMPLETED and bool( - observation.output_text + usable = ( + not oversized_output + and observation.status is CheckerModelProviderStatus.COMPLETED + and bool(observation.output_text) ) async with self._sessions() as session, session.begin(): authority = await self._authorization(session, effect.internal_call_request_id) @@ -348,6 +354,8 @@ async def _complete( response_evidence=evidence, usage=usage, ) + if oversized_output: + return _unavailable("model_response_too_large", call_index) if not usable: return _unavailable("model_provider_failed", call_index) # Fresh current-run attachment gates image access; late completion itself remains retained. @@ -376,6 +384,10 @@ async def _cached(self, facts, effect, profile, call_index: int) -> ModelProxyRe if body.get("schema_version") != "workstream.checker_model_response.v1": raise ModelCallUnavailable("model_call_response_unavailable") output = body["observation"]["output_text"] + if not isinstance(output, str) or ( + len(output.encode("utf-8")) > profile.spec.maximum_output_bytes + ): + return _unavailable("model_response_too_large", call_index) receipt = canonical_json_hash(completed.model_dump(mode="json", exclude={"replayed"})) return ModelProxyReply( outcome="completed", diff --git a/backend/app/adapters/checkers/model_proxy_transport.py b/backend/app/adapters/checkers/model_proxy_transport.py index ab4d5ac68..3283484bd 100644 --- a/backend/app/adapters/checkers/model_proxy_transport.py +++ b/backend/app/adapters/checkers/model_proxy_transport.py @@ -5,9 +5,9 @@ import asyncio import json import os -from pathlib import Path import stat import struct +from pathlib import Path from typing import Any from app.interfaces.checker_model_proxy import ( @@ -18,6 +18,13 @@ ModelProxyRequest, ModelProxyResolvedReceipt, ) +from app.interfaces.setup_model_proxy import ( + SETUP_MODEL_PROXY_PROTOCOL, + SetupModelProxyHandler, + SetupModelProxyReply, + SetupModelProxyRequest, + setup_model_proxy_request_from_wire, +) class ModelProxyTransportError(RuntimeError): @@ -40,6 +47,7 @@ def __init__( *, socket_path: Path, handler: ModelProxyHandler, + setup_handler: SetupModelProxyHandler | None = None, maximum_connections: int = 16, read_timeout_seconds: float = 2.0, write_timeout_seconds: float = 2.0, @@ -48,6 +56,7 @@ def __init__( if ( not isinstance(socket_path, Path) or not callable(handler) + or (setup_handler is not None and not callable(setup_handler)) or type(maximum_connections) is not int or not 1 <= maximum_connections <= 256 or type(read_timeout_seconds) is not float @@ -60,13 +69,14 @@ def __init__( raise ValueError("model proxy transport configuration is invalid") self._socket_path = socket_path self._handler = handler + self._setup_handler = setup_handler self._maximum_connections = maximum_connections self._read_timeout_seconds = read_timeout_seconds self._write_timeout_seconds = write_timeout_seconds self._shutdown_timeout_seconds = shutdown_timeout_seconds self._server: asyncio.AbstractServer | None = None self._connection_tasks: set[asyncio.Task[None]] = set() - self._handler_tasks: set[asyncio.Task[ModelProxyReply]] = set() + self._handler_tasks: set[asyncio.Task[ModelProxyReply | SetupModelProxyReply]] = set() self._parent_identity: tuple[int, int] | None = None self._socket_identity: tuple[int, int] | None = None self._closing = False @@ -200,7 +210,9 @@ def _connection_finished(self, task: asyncio.Task[None]) -> None: if not task.cancelled(): task.exception() - def _handler_finished(self, task: asyncio.Task[ModelProxyReply]) -> None: + def _handler_finished( + self, task: asyncio.Task[ModelProxyReply | SetupModelProxyReply] + ) -> None: self._handler_tasks.discard(task) if not task.cancelled(): task.exception() @@ -217,9 +229,14 @@ async def _serve_connection( if size == 0 or size > MODEL_PROXY_MAXIMUM_FRAME_BYTES: return payload = await reader.readexactly(size) - request = _decode_request(payload) + request = _decode_wire_request(payload) + handler = self._handler + if type(request) is SetupModelProxyRequest: + if self._setup_handler is None: + return + handler = self._setup_handler try: - awaitable = self._handler(request) + awaitable = handler(request) except BaseException: return handler_task = asyncio.create_task( @@ -232,7 +249,7 @@ async def _serve_connection( reply = await asyncio.shield(handler_task) except BaseException: return - if type(reply) is not ModelProxyReply: + if type(reply) not in {ModelProxyReply, SetupModelProxyReply}: return body = json.dumps( reply.wire_value(), @@ -319,6 +336,19 @@ def _decode_request(payload: bytes) -> ModelProxyRequest: ) +def _decode_wire_request(payload: bytes) -> ModelProxyRequest | SetupModelProxyRequest: + value = json.loads( + payload.decode("utf-8"), + parse_constant=_reject_json_constant, + object_pairs_hook=_closed_json_object, + ) + if type(value) is not dict: + raise ValueError("model proxy request root is invalid") + if value.get("protocol_version") == SETUP_MODEL_PROXY_PROTOCOL: + return setup_model_proxy_request_from_wire(value) + return _decode_request(payload) + + def _resolved_receipt_values(value: object) -> list[dict[str, object]]: """Reject open or duplicate receipt objects before constructing trusted values.""" if type(value) is not list: diff --git a/backend/app/adapters/checkers/model_runtime.py b/backend/app/adapters/checkers/model_runtime.py index 8311fa912..84c9e9790 100644 --- a/backend/app/adapters/checkers/model_runtime.py +++ b/backend/app/adapters/checkers/model_runtime.py @@ -1,7 +1,7 @@ """Compose the private model listener with the existing AUTH, CHECKERS and ART owners.""" -from contextlib import asynccontextmanager import os +from contextlib import asynccontextmanager from app.adapters.artifacts import ( artifact_preparation_service, @@ -11,9 +11,15 @@ configured_artifact_storage_namespace, create_artifact_scratch_manager, create_artifact_store_bootstrap, + setup_model_evidence, + setup_model_output_binding, + setup_model_output_storage, validate_configured_artifact_storage_namespace, ) -from app.adapters.auth import checker_model_service_authorization +from app.adapters.auth import ( + checker_model_service_authorization, + setup_model_service_authorization, +) from app.adapters.checkers import ( checker_model_proxy, create_checker_model_provider, @@ -25,6 +31,12 @@ OpenAIResponsesSettings, ) from app.adapters.checkers.model_proxy_transport import ModelProxyTransport +from app.adapters.model_usage import setup_model_usage_service +from app.adapters.project_agents.setup_model_handler import ( + SetupModelProxyHandler, + configured_setup_provider, +) +from app.adapters.projects import setup_model_usage_owner from app.modules.model_usage.api import ModelCallUnavailable, ProjectModelProfile @@ -83,6 +95,14 @@ async def configured_model_proxy_runtime(settings, sessions): preparation=artifact_preparation_service(manager), settings=settings, ) + setup_outputs = setup_model_output_storage( + sessions=sessions, + store=store, + namespace=namespace, + preparation=artifact_preparation_service(manager), + settings=settings, + owner=setup_model_usage_owner, + ) proxy = checker_model_proxy( sessions=sessions, authorization=checker_model_service_authorization, @@ -93,9 +113,28 @@ async def configured_model_proxy_runtime(settings, sessions): bindings=lambda session: checker_output_binding(session, namespace=namespace), provider=configured_model_provider, ) + setup_proxy = SetupModelProxyHandler( + sessions=sessions, + authorization=setup_model_service_authorization, + calls=lambda session, authority: setup_model_usage_service( + session, + authority=authority, + evidence=setup_model_evidence(session), + owner=setup_model_usage_owner(session), + ), + profiles=model_profile_reader, + outputs=setup_outputs, + bindings=lambda session: setup_model_output_binding( + session, + namespace=namespace, + owner=setup_model_usage_owner(session), + ), + provider=configured_setup_provider, + ) async with ModelProxyTransport( socket_path=settings.checker_model_proxy_socket, handler=proxy.handle, + setup_handler=setup_proxy.handle, shutdown_timeout_seconds=7200.0, ): yield diff --git a/backend/app/adapters/project_agents/omnicore_agent.py b/backend/app/adapters/project_agents/omnicore_agent.py index 72b5f36ee..465ab928c 100644 --- a/backend/app/adapters/project_agents/omnicore_agent.py +++ b/backend/app/adapters/project_agents/omnicore_agent.py @@ -34,6 +34,7 @@ ) from app.adapters.project_agents.provider_resilience import ModelCircuitAdmission from app.adapters.project_agents.result_validation import validate_project_guide_runtime_result +from app.adapters.project_agents.setup_model_proxy import SetupModelProxyUnavailable from app.interfaces.external_services import ExternalServiceAdapterIdentity from app.interfaces.project_agents import ( ProjectAgentRuntimeConfigurationError, @@ -47,7 +48,6 @@ from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration from app.modules.projects.api.guide_documents import GuideRuntimeCapabilities - _OPENROUTER_CHAT_COMPLETIONS_URL = "https://openrouter.ai/api/v1/chat/completions" _READ_DESCRIPTION = "Read bounded text from an assigned original using its exact opaque handle." _SEARCH_DESCRIPTION = "Search bounded text in an assigned original using its exact opaque handle." @@ -184,17 +184,16 @@ def __init__( raise ProjectAgentRuntimeConfigurationError( "project guide runtime requires draft and review turns" ) - credential = os.environ.get("OPENROUTER_API_KEY") - if not credential: - raise ProjectAgentRuntimeConfigurationError( - "project guide model credentials are unavailable" - ) + test_upstream = _validated_test_upstream(_test_upstream, _test_authority) + credential = ( + os.environ.get("OPENROUTER_API_KEY") if test_upstream is not None else None + ) or "setup-model-proxy-" + secrets.token_hex(16) self._profile = require_omnicore_agent_adapter_profile( configuration.adapter_profile_values() ) self._configuration = configuration self._credential = credential - self._test_upstream = _validated_test_upstream(_test_upstream, _test_authority) + self._test_upstream = test_upstream self._admission: ModelCircuitAdmission | None = None self._closed = False self._close_task: asyncio.Task[None] | None = None @@ -318,6 +317,8 @@ async def _run( admission = self._admission if admission is None: raise ProjectAgentRuntimeError("project guide runtime was not admitted") + if capabilities.model is None: + raise ProjectAgentRuntimeError("project guide model capability is unavailable") maximum_requests = configuration.maximum_turns * OMNICORE_PROVIDER_ATTEMPTS guard = _LoopbackOpenRouterGuard( credential=self._credential, @@ -328,6 +329,9 @@ async def _run( maximum_logical_turns=configuration.maximum_turns, io_timeout_seconds=max(1, min(300, configuration.timeout_seconds)), upstream=self._test_upstream, + dispatch=None if self._test_upstream is not None else capabilities.model, + reasoning_effort=capabilities.model.reasoning_effort, + text_verbosity=capabilities.model.text_verbosity, ) agents: list[Any] = [] await guard.start() @@ -375,6 +379,7 @@ async def search_document( async def run_phase(phase: str, phase_prompt: str, turn_ceiling: int) -> dict: nonlocal corrections + guard.phase = "draft" if phase == "checker_specification" else "review" guard.phase_turn_ceiling = turn_ceiling while True: remaining = turn_ceiling - guard.logical_turns @@ -389,6 +394,9 @@ async def run_phase(phase: str, phase_prompt: str, turn_ceiling: int) -> dict: base_url=guard.base_url, registry=registry, maximum_steps=remaining, + reasoning_effort=capabilities.model.reasoning_effort, + text_verbosity=capabilities.model.text_verbosity, + maximum_output_tokens=capabilities.model.maximum_output_tokens, phase_instruction=( "Return only one complete JSON object matching this exact " "checker-specification schema: " @@ -419,6 +427,7 @@ async def run_phase(phase: str, phase_prompt: str, turn_ceiling: int) -> dict: if corrections >= 2 or turn_ceiling - guard.logical_turns < 2: raise ProjectGuideCompilationInvalidOutputError("schema_invalid") corrections += 1 + guard.phase = "correction" phase_prompt = json.dumps( { "phase": phase, @@ -475,7 +484,9 @@ async def run_phase(phase: str, phase_prompt: str, turn_ceiling: int) -> dict: raise cleanup_error def _require_credential_unchanged(self) -> None: - """Reject SDK lifecycle drift without rewriting process-global state.""" + """Keep fake-upstream tests pinned without reading provider secrets in production.""" + if self._test_upstream is None: + return current = os.environ.get("OPENROUTER_API_KEY") if current is None or not hmac.compare_digest(current, self._credential): raise ProjectAgentRuntimeError("project guide model credential changed") @@ -490,6 +501,9 @@ def _build_agent( base_url: str, registry: Any, maximum_steps: int, + reasoning_effort: str, + text_verbosity: str, + maximum_output_tokens: int, phase_instruction: str, ) -> Any: """Construct one phase with a fresh private in-memory session and no extras.""" @@ -533,7 +547,9 @@ def _build_agent( "api_key": credential, "base_url": base_url, "temperature": 0, - "max_tokens": OMNICORE_MAX_TOKENS, + "max_tokens": min(OMNICORE_MAX_TOKENS, maximum_output_tokens), + "reasoning_effort": reasoning_effort, + "verbosity": text_verbosity, }, mcp_tools=[], local_tools=registry, @@ -656,6 +672,9 @@ def __init__( maximum_logical_turns: int, io_timeout_seconds: int, upstream: _UpstreamTarget | None, + dispatch=None, + reasoning_effort: str = "medium", + text_verbosity: str = "low", ) -> None: """Bind one attempt's credential, model, limits, and fixed upstream.""" self._credential = credential @@ -665,8 +684,12 @@ def __init__( self._maximum_requests = maximum_requests self._maximum_logical_turns = maximum_logical_turns self.phase_turn_ceiling = maximum_logical_turns + self.phase = "draft" self._io_timeout_seconds = io_timeout_seconds self._upstream_url = (upstream or _PRODUCTION_UPSTREAM).url + self._dispatch = dispatch + self._reasoning_effort = reasoning_effort + self._text_verbosity = text_verbosity self._path_token = secrets.token_urlsafe(32) self._server: asyncio.AbstractServer | None = None self._tasks: set[asyncio.Task[None]] = set() @@ -699,15 +722,16 @@ def logical_turns(self) -> int: return self._logical_turns async def start(self) -> None: - """Open the bounded loopback listener and proxy-free upstream client.""" - import httpx - - self._client = httpx.AsyncClient( - trust_env=False, - follow_redirects=False, - timeout=self._io_timeout_seconds, - ) - self._client.headers.clear() + """Open the bounded loopback listener and an optional fake-test upstream.""" + if self._dispatch is None: + import httpx + + self._client = httpx.AsyncClient( + trust_env=False, + follow_redirects=False, + timeout=self._io_timeout_seconds, + ) + self._client.headers.clear() try: self._server = await asyncio.start_server( self._accepted, @@ -717,13 +741,15 @@ async def start(self) -> None: start_serving=True, ) except BaseException: - await self._client.aclose() + if self._client is not None: + await self._client.aclose() raise sockets = self._server.sockets or [] if len(sockets) != 1: self._server.close() await self._server.wait_closed() - await self._client.aclose() + if self._client is not None: + await self._client.aclose() raise RuntimeError("OpenRouter guard listener is invalid") self._port = int(sockets[0].getsockname()[1]) @@ -896,13 +922,50 @@ async def _forward(self, request: _ParsedRequest) -> tuple[int, Mapping[str, str self._logical_request_active = True self._logical_turns += 1 self._last_upstream_transient_failure = False - outbound = _rebuild_openrouter_body(request.body, model=self._model) + outbound = _rebuild_openrouter_body( + request.body, + model=self._model, + reasoning_effort=self._reasoning_effort, + text_verbosity=self._text_verbosity, + ) request_body = json.dumps( outbound, ensure_ascii=False, allow_nan=False, separators=(",", ":"), ).encode("utf-8") + if self._dispatch is not None: + try: + reply = await self._dispatch.dispatch(self.phase, outbound) + except SetupModelProxyUnavailable: + self._last_upstream_transient_failure = True + raise _GuardRejection(502) from None + assert reply.status_code is not None and reply.response_json is not None + response_body = reply.response_json.encode("utf-8") + self._last_upstream_transient_failure = ( + reply.status_code in {408, 409, 429} or reply.status_code >= 500 + ) + if len(response_body) > self._profile.maximum_response_bytes: + self._last_upstream_transient_failure = False + return 422, {}, _SANITIZED_PROVIDER_ERROR_BODY + if not 200 <= reply.status_code < 300: + return ( + reply.status_code, + dict(reply.response_headers), + _SANITIZED_PROVIDER_ERROR_BODY, + ) + try: + response_body = _rebuild_provider_success_body( + response_body, + expected_model=self._model, + ) + except (UnicodeDecodeError, ValueError, RecursionError, _GuardRejection): + self._last_upstream_transient_failure = False + return 422, {}, _SANITIZED_PROVIDER_ERROR_BODY + self._admission.request_succeeded() + self._logical_request_active = False + return reply.status_code, dict(reply.response_headers), response_body + assert self._client is not None self._client.cookies.clear() try: async with self._client.stream( @@ -1096,7 +1159,13 @@ def _validate_tool_call(call: object) -> None: raise _GuardRejection(400) -def _rebuild_openrouter_body(body: dict[str, Any], *, model: str) -> dict[str, Any]: +def _rebuild_openrouter_body( + body: dict[str, Any], + *, + model: str, + reasoning_effort: str, + text_verbosity: str, +) -> dict[str, Any]: """Build trusted OpenRouter state instead of forwarding caller objects.""" rebuilt = { key: body[key] @@ -1115,6 +1184,8 @@ def _rebuild_openrouter_body(body: dict[str, Any], *, model: str) -> dict[str, A "model": model, "tools": _MODEL_TOOLS, "usage": {"include": True}, + "reasoning_effort": reasoning_effort, + "verbosity": text_verbosity, **openrouter_request_options(), } ) diff --git a/backend/app/adapters/project_agents/openai_agent_sdk.py b/backend/app/adapters/project_agents/openai_agent_sdk.py index 3e072be5f..d98f5672d 100644 --- a/backend/app/adapters/project_agents/openai_agent_sdk.py +++ b/backend/app/adapters/project_agents/openai_agent_sdk.py @@ -4,12 +4,11 @@ import asyncio import json -import os from app.adapters.project_agents.compilation_draft import ( CompilationDraftParser, - generated_compilation_schema, contextual_output_issue, + generated_compilation_schema, ) from app.adapters.project_agents.openai_profile import ( require_openai_agents_sdk_adapter_profile, @@ -23,6 +22,7 @@ require_result_sources_opened, validate_project_guide_runtime_result, ) +from app.adapters.project_agents.setup_model_proxy import create_setup_httpx_client from app.interfaces.external_services import ExternalServiceAdapterIdentity from app.interfaces.project_agents import ( ProjectAgentRuntimeConfigurationError, @@ -34,13 +34,12 @@ project_guide_compilation_prompt_bytes, ) from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration -from .openrouter_policy import openrouter_request_options, require_direct_openrouter_model from app.modules.projects.api.guide_documents import GuideRuntimeCapabilities +from .openrouter_policy import openrouter_request_options, require_direct_openrouter_model _INVALID_SCHEMA_OUTPUT = object() -_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" -_OPENAI_BASE_URL = "https://api.openai.com/v1" +_PROXY_BASE_URL = "https://model-proxy.invalid/v1" @@ -57,7 +56,7 @@ def __init__(self, configuration: ProjectGuideRuntimeConfiguration) -> None: raise ProjectAgentRuntimeConfigurationError( "project guide runtime requires draft and review turns" ) - self._provider = _provider_configuration(configuration) + _require_supported_provider(configuration) self._profile = require_openai_agents_sdk_adapter_profile( configuration.adapter_profile_values() ) @@ -157,12 +156,15 @@ async def _run( function_tool, ) from agents.exceptions import ModelBehaviorError - from openai import AsyncOpenAI, DefaultAsyncHttpx2Client + from openai import AsyncOpenAI configuration = self._configuration profile = self._profile admission = self._admission assert admission is not None + if capabilities.model is None: + raise ProjectAgentRuntimeError("project guide model capability is unavailable") + active_phase = "draft" phase_opened: set[str] = set() @@ -259,13 +261,16 @@ async def get_response( admission.request_succeeded() return response - http_client = DefaultAsyncHttpx2Client( - trust_env=False, - follow_redirects=False, + http_client = create_setup_httpx_client( + capabilities.model, + model=configuration.model, + api=configuration.model_api, + phase=lambda: active_phase, + timeout_seconds=float(profile.request_timeout_seconds), ) client = AsyncOpenAI( - api_key=self._provider.api_key, - base_url=self._provider.base_url, + api_key="setup-model-proxy", + base_url=_PROXY_BASE_URL, http_client=http_client, max_retries=0, timeout=profile.request_timeout_seconds, @@ -314,6 +319,9 @@ async def search_document( model_settings = ModelSettings( parallel_tool_calls=False, store=False, + max_tokens=capabilities.model.maximum_output_tokens, + reasoning={"effort": capabilities.model.reasoning_effort}, + verbosity=capabilities.model.text_verbosity, timeout=profile.request_timeout_seconds, retry=model_retry_settings(profile), extra_body=extra_body, @@ -335,7 +343,8 @@ async def search_document( async def run_phase(phase, phase_prompt, turn_limit): """Correct known invalid responses without replaying uncertain requests.""" - nonlocal corrections + nonlocal active_phase, corrections + active_phase = "draft" if phase == "checker_specification" else "review" while True: remaining = turn_limit - agent.model.model_turns if remaining <= 0: @@ -356,6 +365,7 @@ async def run_phase(phase, phase_prompt, turn_limit): if corrections >= 2: raise ProjectGuideCompilationInvalidOutputError("schema_invalid") corrections += 1 + active_phase = "correction" phase_prompt = json.dumps( { "phase": phase, @@ -410,37 +420,21 @@ async def run_phase(phase, phase_prompt, turn_limit): await client.close() -class _ProviderConfiguration: - __slots__ = ("api_key", "base_url") - - def __init__(self, api_key: str, base_url: str) -> None: - self.api_key = api_key - self.base_url = base_url - - -def _provider_configuration( +def _require_supported_provider( configuration: ProjectGuideRuntimeConfiguration, -) -> _ProviderConfiguration: +) -> None: if ( configuration.model_provider == "openrouter" and configuration.model_api == "chat_completions" ): require_direct_openrouter_model(configuration.model) - credential_name = "OPENROUTER_API_KEY" - base_url = _OPENROUTER_BASE_URL + return elif configuration.model_provider == "openai" and configuration.model_api in { "responses", "chat_completions", }: - credential_name = "OPENAI_API_KEY" - base_url = _OPENAI_BASE_URL + return else: raise ProjectAgentRuntimeConfigurationError( "project guide model provider or protocol is unsupported" ) - api_key = os.environ.get(credential_name) - if not api_key: - raise ProjectAgentRuntimeConfigurationError( - "project guide model credentials are unavailable" - ) - return _ProviderConfiguration(api_key, base_url) diff --git a/backend/app/adapters/project_agents/setup_model_handler.py b/backend/app/adapters/project_agents/setup_model_handler.py new file mode 100644 index 000000000..594cc2135 --- /dev/null +++ b/backend/app/adapters/project_agents/setup_model_handler.py @@ -0,0 +1,861 @@ +"""Trusted setup-agent model handler with durable budget and ART custody.""" + +from __future__ import annotations + +import hashlib +import json +import os +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from dataclasses import dataclass +from decimal import ROUND_CEILING, Decimal, InvalidOperation +from typing import Protocol + +from app.adapters.project_agents.openrouter_policy import openrouter_request_options +from app.core.hashing import canonical_json_hash +from app.interfaces.setup_model_proxy import ( + SetupModelProxyReply, + SetupModelProxyRequest, +) +from app.modules.model_usage.api import ( + CompletedSetupModelUsageEffect, + ModelCallBudgetUnavailable, + ModelCallEvidence, + ModelCallUnavailable, + ModelCallUsage, + ModelProfileSelector, + PreparedSetupModelUsageEffect, + ProjectModelProfile, + SetupModelOutputArtifactRequest, + SetupModelOutputBindingRequest, + SetupModelOutputSelector, + SetupModelUsageSlotKey, +) + +_OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses" +_OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions" +_OPENROUTER_MODEL_URL = "https://openrouter.ai/api/v1/model/{author}/{slug}" +_OPENROUTER_REQUIRED_PARAMETERS = frozenset( + {"max_tokens", "reasoning_effort", "tool_choice", "tools", "verbosity"} +) +_OPENAI_RESPONSE_FIELDS = frozenset( + { + "background", + "include", + "input", + "instructions", + "max_output_tokens", + "model", + "parallel_tool_calls", + "previous_response_id", + "reasoning", + "store", + "stream", + "text", + "tool_choice", + "tools", + "truncation", + } +) +_COUNT_FIELDS = frozenset( + {"input", "instructions", "model", "parallel_tool_calls", "tools", "truncation"} +) +_OPENROUTER_CHAT_FIELDS = frozenset( + { + "max_tokens", + "messages", + "model", + "parallel_tool_calls", + "plugins", + "provider", + "reasoning_effort", + "response_format", + "store", + "stream", + "temperature", + "tool_choice", + "tools", + "top_p", + "usage", + "verbosity", + } +) + + +@dataclass(frozen=True, slots=True) +class SetupProviderResponse: + """One fully observed provider response with exact raw JSON and usage.""" + + status_code: int + response_json: str + provider_response_id: str + provider_model: str + usage: ModelCallUsage + usable: bool = True + + +class SetupProviderUncertain(RuntimeError): + """A provider dispatch may have created a paid effect.""" + + def __init__(self, digest: str, provider_response_id: str | None = None) -> None: + super().__init__("setup provider outcome is uncertain") + self.digest = digest + self.provider_response_id = provider_response_id + + +class SetupProvider(Protocol): + """Closed provider boundary used by the trusted setup model handler.""" + + async def count_input(self, body: dict[str, object]) -> int: ... + + async def dispatch(self, body_json: str) -> SetupProviderResponse: ... + + +class OpenAISetupProvider: + """No-retry OpenAI Responses adapter owned only by the trusted model process.""" + + def __init__(self, *, api_key: str, profile: ProjectModelProfile) -> None: + import httpx + from openai import AsyncOpenAI + + self._profile = profile + self._counter = AsyncOpenAI( + api_key=api_key, + max_retries=0, + timeout=profile.spec.deadline_ms / 1000.0, + ) + self._client = httpx.AsyncClient( + trust_env=False, + follow_redirects=False, + timeout=profile.spec.deadline_ms / 1000.0, + headers={ + "Authorization": "Bearer " + api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + + async def count_input(self, body: dict[str, object]) -> int: + """Use the provider's non-creating endpoint on the exact semantic input.""" + arguments = {key: value for key, value in body.items() if key in _COUNT_FIELDS} + counted = await self._counter.responses.input_tokens.count(**arguments) + value = getattr(counted, "input_tokens", None) + if type(value) is not int or value < 0: + raise ModelCallUnavailable("setup_model_input_count_unavailable") + return value + + async def dispatch(self, body_json: str) -> SetupProviderResponse: + """Send exactly once; every exception or non-success remains uncertain.""" + raw = b"" + try: + async with self._client.stream( + "POST", _OPENAI_RESPONSES_URL, content=body_json + ) as response: + raw = await _bounded_response_bytes( + response, self._profile.spec.maximum_response_bytes + ) + except BaseException as exc: + raise SetupProviderUncertain( + canonical_json_hash({"outcome": "transport_error"}) + ) from exc + try: + if not raw: + raise ValueError("provider response exceeds profile") + value = json.loads(raw, parse_constant=_reject_constant) + if not isinstance(value, dict): + raise TypeError("provider response is invalid") + provider_id = value.get("id") + if not 200 <= response.status_code < 300: + raise SetupProviderUncertain( + canonical_json_hash( + {"outcome": "provider_status", "status_code": response.status_code} + ), + provider_id if isinstance(provider_id, str) else None, + ) + canonical = _canonical_bytes(value).decode("utf-8") + provider_model = value.get("model") + usage = _usage(value.get("usage")) + if ( + not isinstance(provider_id, str) + or not provider_id + or provider_model != self._profile.spec.model_snapshot + ): + raise ValueError("provider response identity is invalid") + return SetupProviderResponse( + status_code=response.status_code, + response_json=canonical, + provider_response_id=provider_id, + provider_model=provider_model, + usage=usage, + usable=_model_output_bytes(value) + <= self._profile.spec.maximum_output_bytes, + ) + except SetupProviderUncertain: + raise + except BaseException as exc: + provider_id = None + try: + candidate = json.loads(raw).get("id") + if isinstance(candidate, str): + provider_id = candidate + except (AttributeError, TypeError, ValueError, UnicodeError): + provider_id = None + raise SetupProviderUncertain( + canonical_json_hash( + {"outcome": "invalid_response", "status_code": response.status_code} + ), + provider_id, + ) from exc + + async def aclose(self) -> None: + await self._counter.close() + await self._client.aclose() + + +class OpenRouterSetupProvider: + """No-retry OpenRouter adapter reserving its full pinned model context.""" + + def __init__(self, *, api_key: str, profile: ProjectModelProfile) -> None: + import httpx + + model_parts = profile.spec.model_snapshot.split("/") + if len(model_parts) != 2 or not all(model_parts): + raise ModelCallUnavailable("model_profile_provider_unavailable") + self._profile = profile + self._model_url = _OPENROUTER_MODEL_URL.format( + author=model_parts[0], slug=model_parts[1] + ) + self._client = httpx.AsyncClient( + trust_env=False, + follow_redirects=False, + timeout=profile.spec.deadline_ms / 1000.0, + headers={ + "Authorization": "Bearer " + api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + + async def count_input(self, _body: dict[str, object]) -> int: + """Refresh exact model metadata, then reserve the full approved context.""" + try: + async with self._client.stream("GET", self._model_url) as response: + raw = await _bounded_response_bytes(response, 1_048_576) + if not 200 <= response.status_code < 300 or not raw: + raise ValueError("provider metadata unavailable") + value = json.loads(raw, parse_constant=_reject_constant) + _validate_openrouter_metadata(self._profile, value) + except ModelCallUnavailable: + raise + except BaseException as exc: + raise ModelCallUnavailable("model_profile_provider_unavailable") from exc + return self._profile.spec.maximum_input_tokens + + async def dispatch(self, body_json: str) -> SetupProviderResponse: + raw = b"" + try: + async with self._client.stream( + "POST", _OPENROUTER_CHAT_URL, content=body_json + ) as response: + raw = await _bounded_response_bytes( + response, self._profile.spec.maximum_response_bytes + ) + except BaseException as exc: + raise SetupProviderUncertain( + canonical_json_hash({"outcome": "transport_error"}) + ) from exc + try: + if not raw: + raise ValueError("provider response exceeds profile") + value = json.loads(raw, parse_constant=_reject_constant) + if not isinstance(value, dict): + raise TypeError("provider response is invalid") + provider_id = value.get("id") + if not 200 <= response.status_code < 300: + raise SetupProviderUncertain( + canonical_json_hash( + {"outcome": "provider_status", "status_code": response.status_code} + ), + provider_id if isinstance(provider_id, str) else None, + ) + provider_model = value.get("model") + usage = _chat_usage(value.get("usage")) + if ( + not isinstance(provider_id, str) + or not provider_id + or provider_model != self._profile.spec.model_snapshot + ): + raise ValueError("provider response identity is invalid") + return SetupProviderResponse( + status_code=response.status_code, + response_json=_canonical_bytes(value).decode("utf-8"), + provider_response_id=provider_id, + provider_model=provider_model, + usage=usage, + usable=_chat_output_bytes(value) + <= self._profile.spec.maximum_output_bytes, + ) + except SetupProviderUncertain: + raise + except BaseException as exc: + provider_id = None + try: + candidate = json.loads(raw).get("id") + if isinstance(candidate, str): + provider_id = candidate + except (AttributeError, TypeError, ValueError, UnicodeError): + provider_id = None + raise SetupProviderUncertain( + canonical_json_hash( + {"outcome": "invalid_response", "status_code": response.status_code} + ), + provider_id, + ) from exc + + async def aclose(self) -> None: + await self._client.aclose() + + +class SetupModelProxyHandler: + """Persist request and reservation before exactly one trusted provider send.""" + + def __init__( + self, + *, + sessions, + authorization, + calls, + profiles, + outputs, + bindings, + provider: Callable[ + [ProjectModelProfile], AbstractAsyncContextManager[SetupProvider] + ], + ) -> None: + self._sessions = sessions + self._authorization = authorization + self._calls = calls + self._profiles = profiles + self._outputs = outputs + self._bindings = bindings + self._provider = provider + + async def handle(self, request: SetupModelProxyRequest) -> SetupModelProxyReply: + try: + return await self._handle(request) + except ModelCallBudgetUnavailable: + return _unavailable(request, "model_budget_unavailable") + except (ModelCallUnavailable, ValueError, TypeError): + return _unavailable(request, "model_call_unavailable") + + async def _handle(self, request: SetupModelProxyRequest) -> SetupModelProxyReply: + subject = request.subject + key = SetupModelUsageSlotKey( + compilation_attempt_id=subject.compilation_attempt_id, + project_id=subject.project_id, + guide_id=subject.guide_id, + source_snapshot_id=subject.source_snapshot_id, + setup_run_id=subject.setup_run_id, + setup_generation=subject.setup_generation, + runtime_configuration_hash=subject.runtime_configuration_hash, + profile_id=subject.profile_id, + profile_version=subject.profile_version, + profile_digest=subject.profile_digest, + phase=request.phase, + call_index=request.call_index, + ) + body = json.loads(request.provider_request_json) + async with self._sessions() as session, session.begin(): + profile = await self._profiles(session).read_exact( + ModelProfileSelector( + profile_id=subject.profile_id, + profile_version=subject.profile_version, + profile_digest=subject.profile_digest, + ), + project_id=subject.project_id, + ) + _validate_request(profile, request, body) + data = request.provider_request_json.encode("utf-8") + request_sha = _sha(data) + + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, subject.compilation_attempt_id) + calls = self._calls(session, authority) + completed = await calls.recover_completed(key, request_sha) + if completed is not None: + return await self._cached(request, completed, profile) + + async with self._provider(profile) as provider: + input_tokens = await provider.count_input(body) + if input_tokens > profile.spec.maximum_input_tokens: + raise ModelCallUnavailable("setup_model_input_limit_exceeded") + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, subject.compilation_attempt_id) + prepared = await self._calls(session, authority).prepare(key, request_sha) + request_evidence = await self._store(prepared, "model_request", data) + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, prepared.internal_call_request_id) + dispatch = await self._calls(session, authority).reserve_dispatch( + prepared, request_evidence + ) + if dispatch.replayed: + return _unavailable(request, "model_provider_delivery_uncertain") + try: + response = await provider.dispatch(request.provider_request_json) + except SetupProviderUncertain as uncertain: + async with self._sessions() as session, session.begin(): + authority = await self._authorization( + session, dispatch.internal_call_request_id + ) + await self._calls(session, authority).mark_uncertain( + dispatch.effect, + uncertain.digest, + provider_response_id=uncertain.provider_response_id, + ) + return _unavailable(request, "model_provider_delivery_uncertain") + response_data = response.response_json.encode("utf-8") + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, dispatch.internal_call_request_id) + await self._calls(session, authority).mark_uncertain( + dispatch.effect, + canonical_json_hash( + { + "outcome": "provider_response_received", + "response_sha256": _sha(response_data), + } + ), + provider_response_id=response.provider_response_id, + ) + response_evidence = await self._store(prepared, "model_response", response_data) + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, dispatch.internal_call_request_id) + completed = await self._calls(session, authority).complete( + dispatch.effect, + provider_response_id=response.provider_response_id, + provider_model_snapshot=response.provider_model, + response_evidence=response_evidence, + usage=response.usage, + usable=response.usable, + ) + if not response.usable: + return _unavailable(request, "model_response_too_large") + return _completed_reply( + request, + profile, + status_code=response.status_code, + response_json=response.response_json, + ) + + async def _store( + self, prepared: PreparedSetupModelUsageEffect, role: str, data: bytes + ) -> ModelCallEvidence: + selector = SetupModelOutputSelector( + slot_key=prepared.slot_key, + model_call_slot_id=prepared.slot_id, + model_call_attempt_id=prepared.effect_id, + role=role, + content_sha256=_sha(data), + ) + + async def source(): + yield data + + stored = await self._outputs.store_setup_output( + SetupModelOutputArtifactRequest(selector, source()) + ) + if stored.status != "verified" or stored.verification_receipt_id is None: + raise ModelCallUnavailable("setup_model_evidence_unavailable") + async with self._sessions() as session, session.begin(): + binding = await self._bindings(session).bind_setup_output( + SetupModelOutputBindingRequest( + selector, + stored.put_attempt_id, + stored.verification_receipt_id, + ) + ) + return ModelCallEvidence( + binding_id=binding.binding_id, + content_id=binding.content_id, + put_attempt_id=binding.put_attempt_id, + verification_receipt_id=binding.verification_receipt_id, + content_sha256=selector.content_sha256, + byte_count=len(data), + ) + + async def _cached( + self, + request: SetupModelProxyRequest, + completed: CompletedSetupModelUsageEffect, + profile: ProjectModelProfile, + ) -> SetupModelProxyReply: + selector = SetupModelOutputSelector( + slot_key=completed.effect.slot_key, + model_call_slot_id=completed.effect.slot_id, + model_call_attempt_id=completed.effect.effect_id, + role="model_response", + content_sha256=completed.response_evidence.content_sha256, + ) + data = await self._outputs.read_setup_response(selector, completed.response_evidence) + body = json.loads(data) + response_json = _canonical_bytes(body).decode("utf-8") + return _completed_reply( + request, + profile, + status_code=200, + response_json=response_json, + ) + + +@asynccontextmanager +async def configured_setup_provider(profile: ProjectModelProfile): + """Construct only the closed provider/API pair supported with exact pre-counting.""" + identity = profile.spec.provider, profile.spec.api + if identity == ("openai", "responses"): + key_name = "OPENAI_API_KEY" + provider_type = OpenAISetupProvider + elif identity == ("openrouter", "chat_completions"): + key_name = "OPENROUTER_API_KEY" + provider_type = OpenRouterSetupProvider + else: + raise ModelCallUnavailable("model_profile_provider_unavailable") + key = os.environ.get(key_name, "") + if not key: + raise ModelCallUnavailable("model_provider_credentials_unavailable") + provider = provider_type(api_key=key, profile=profile) + try: + yield provider + finally: + await provider.aclose() + + +def _validate_request( + profile: ProjectModelProfile, + request: SetupModelProxyRequest, + body: object, +) -> None: + subject = request.subject + if ( + not isinstance(body, dict) + or (profile.spec.provider, profile.spec.api, profile.spec.model_snapshot) + != (subject.provider, subject.api, subject.model) + or profile.spec.allowed_tools != ("read_document", "search_document") + or body.get("model") != subject.model + or body.get("stream", False) is not False + or len(request.provider_request_json.encode("utf-8")) + > profile.spec.maximum_request_bytes + or not _safe_tools(body.get("tools")) + ): + raise ModelCallUnavailable("setup_model_request_invalid") + identity = subject.provider, subject.api + if identity == ("openai", "responses"): + if ( + set(body) - _OPENAI_RESPONSE_FIELDS + or body.get("background", False) is not False + or body.get("store", False) is not False + or body.get("previous_response_id") not in {None, ""} + or body.get("parallel_tool_calls") is not False + or body.get("truncation") not in {None, "disabled"} + or body.get("include", []) != [] + or body.get("reasoning") + != {"effort": profile.spec.settings.reasoning_effort} + or not _safe_response_text( + body.get("text"), profile.spec.settings.text_verbosity + ) + or type(body.get("max_output_tokens")) is not int + or not 1 + <= body["max_output_tokens"] + <= profile.spec.maximum_output_tokens + ): + raise ModelCallUnavailable("setup_model_request_invalid") + elif identity == ("openrouter", "chat_completions"): + if ( + set(body) - _OPENROUTER_CHAT_FIELDS + or type(body.get("max_tokens")) is not int + or not 1 <= body["max_tokens"] <= profile.spec.maximum_output_tokens + or body.get("usage") != {"include": True} + or body.get("provider") != openrouter_request_options()["provider"] + or body.get("plugins") != openrouter_request_options()["plugins"] + or body.get("reasoning_effort") != profile.spec.settings.reasoning_effort + or body.get("verbosity") != profile.spec.settings.text_verbosity + or body.get("temperature", 0) != 0 + or body.get("top_p", 1) != 1 + or body.get("store", False) is not False + or body.get("parallel_tool_calls", False) is not False + or not _safe_chat_response_format(body.get("response_format")) + ): + raise ModelCallUnavailable("setup_model_request_invalid") + else: + raise ModelCallUnavailable("setup_model_request_invalid") + + +def _safe_tools(value: object) -> bool: + if not isinstance(value, list) or len(value) != 2: + return False + names: list[str] = [] + for item in value: + if not isinstance(item, dict) or item.get("type") != "function": + return False + name = item.get("name") + if name is None and isinstance(item.get("function"), dict): + name = item["function"].get("name") + if not isinstance(name, str): + return False + names.append(name) + return sorted(names) == ["read_document", "search_document"] + + +def _safe_response_text(value: object, verbosity: str) -> bool: + if not isinstance(value, dict) or set(value) - {"format", "verbosity"}: + return False + output_format = value.get("format") + return ( + value.get("verbosity") == verbosity + and isinstance(output_format, dict) + and output_format.get("type") == "json_schema" + and output_format.get("strict") is True + and isinstance(output_format.get("name"), str) + and isinstance(output_format.get("schema"), dict) + ) + + +def _safe_chat_response_format(value: object) -> bool: + if value is None: + return True + if not isinstance(value, dict) or set(value) != {"type", "json_schema"}: + return False + schema = value.get("json_schema") + return ( + value.get("type") == "json_schema" + and isinstance(schema, dict) + and schema.get("strict") is True + and isinstance(schema.get("name"), str) + and isinstance(schema.get("schema"), dict) + and not set(schema) - {"name", "strict", "schema", "description"} + ) + + +def _validate_openrouter_metadata( + profile: ProjectModelProfile, value: object +) -> None: + """Fail before spend unless current provider metadata fits the pinned profile.""" + if not isinstance(value, dict) or set(value) != {"data"}: + raise ModelCallUnavailable("model_profile_provider_unavailable") + data = value["data"] + if not isinstance(data, dict): + raise ModelCallUnavailable("model_profile_provider_unavailable") + top_provider = data.get("top_provider") + supported = data.get("supported_parameters") + context_length = data.get("context_length") + if ( + data.get("id") != profile.spec.model_snapshot + or not isinstance(data.get("canonical_slug"), str) + or not data["canonical_slug"] + or type(context_length) is not int + or context_length < 1 + or context_length > profile.spec.maximum_input_tokens + or not isinstance(top_provider, dict) + or type(top_provider.get("context_length")) is not int + or top_provider["context_length"] != context_length + or type(top_provider.get("max_completion_tokens")) is not int + or not 1 + <= profile.spec.maximum_output_tokens + <= top_provider["max_completion_tokens"] + or not isinstance(supported, list) + or any(type(item) is not str for item in supported) + or not _OPENROUTER_REQUIRED_PARAMETERS.issubset(supported) + ): + raise ModelCallUnavailable("model_profile_provider_unavailable") + _validate_openrouter_pricing(profile, data.get("pricing")) + + +def _validate_openrouter_pricing( + profile: ProjectModelProfile, value: object +) -> None: + """Require pinned rates to cover every advertised tier and cache class.""" + if not isinstance(value, dict): + raise ModelCallUnavailable("model_profile_provider_unavailable") + pricing_rows = [value] + overrides = value.get("overrides", []) + if not isinstance(overrides, list) or any(not isinstance(row, dict) for row in overrides): + raise ModelCallUnavailable("model_profile_provider_unavailable") + pricing_rows.extend(overrides) + input_ceiling = 0 + cached_ceiling = 0 + output_ceiling = 0 + for row in pricing_rows: + input_ceiling = max( + input_ceiling, + _nanodollars(row.get("prompt")), + _nanodollars(row.get("input_cache_write", row.get("prompt"))), + ) + cached_ceiling = max( + cached_ceiling, + _nanodollars(row.get("input_cache_read", row.get("prompt"))), + ) + output_ceiling = max(output_ceiling, _nanodollars(row.get("completion"))) + pinned = profile.spec.pricing + if ( + input_ceiling > pinned.uncached_input_nanodollars_per_token + or cached_ceiling > pinned.cached_input_nanodollars_per_token + or output_ceiling > pinned.output_nanodollars_per_token + ): + raise ModelCallUnavailable("model_profile_provider_unavailable") + + +def _nanodollars(value: object) -> int: + if type(value) is not str: + raise ModelCallUnavailable("model_profile_provider_unavailable") + try: + amount = Decimal(value) + except InvalidOperation: + raise ModelCallUnavailable("model_profile_provider_unavailable") from None + if not amount.is_finite() or amount < 0: + raise ModelCallUnavailable("model_profile_provider_unavailable") + converted = (amount * Decimal(1_000_000_000)).to_integral_value( + rounding=ROUND_CEILING + ) + if converted > 9_000_000_000_000_000_000: + raise ModelCallUnavailable("model_profile_provider_unavailable") + return int(converted) + + +def _usage(value: object) -> ModelCallUsage: + if not isinstance(value, dict): + raise TypeError("provider usage is unavailable") + input_details = value.get("input_tokens_details") or {} + output_details = value.get("output_tokens_details") or {} + return ModelCallUsage( + input_tokens=value.get("input_tokens"), + cached_input_tokens=input_details.get("cached_tokens", 0), + output_tokens=value.get("output_tokens"), + reasoning_tokens=output_details.get("reasoning_tokens", 0), + ) + + +def _chat_usage(value: object) -> ModelCallUsage: + if not isinstance(value, dict): + raise TypeError("provider usage is unavailable") + input_details = value.get("prompt_tokens_details") or {} + output_details = value.get("completion_tokens_details") or {} + return ModelCallUsage( + input_tokens=value.get("prompt_tokens"), + cached_input_tokens=input_details.get("cached_tokens", 0), + output_tokens=value.get("completion_tokens"), + reasoning_tokens=output_details.get("reasoning_tokens", 0), + ) + + +def _model_output_bytes(value: dict[str, object]) -> int: + """Count UTF-8 model-authored text and tool arguments without response metadata.""" + output = value.get("output") + if not isinstance(output, list): + raise TypeError("provider output is invalid") + total = 0 + stack: list[object] = list(output) + while stack: + current = stack.pop() + if isinstance(current, list): + stack.extend(current) + elif isinstance(current, dict): + for key, item in current.items(): + if key in {"text", "refusal", "arguments"} and isinstance(item, str): + total += len(item.encode("utf-8")) + elif isinstance(item, (dict, list)): + stack.append(item) + return total + + +def _chat_output_bytes(value: dict[str, object]) -> int: + choices = value.get("choices") + if not isinstance(choices, list): + raise TypeError("provider output is invalid") + return _generated_string_bytes(choices) + + +def _generated_string_bytes(root: object) -> int: + total = 0 + stack = [root] + while stack: + current = stack.pop() + if isinstance(current, list): + stack.extend(current) + elif isinstance(current, dict): + for key, item in current.items(): + if key in {"content", "refusal", "arguments"} and isinstance(item, str): + total += len(item.encode("utf-8")) + elif isinstance(item, (dict, list)): + stack.append(item) + return total + + +def _canonical_bytes(value: object) -> bytes: + return json.dumps( + value, + sort_keys=True, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + + +async def _bounded_response_bytes(response, maximum_bytes: int) -> bytes: + """Read a provider body without ever accumulating beyond its locked cap.""" + body = bytearray() + async for chunk in response.aiter_bytes(): + if not chunk: + continue + if len(chunk) > maximum_bytes - len(body): + raise ValueError("provider response exceeds profile") + body.extend(chunk) + return bytes(body) + + +def _sha(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def _reject_constant(_: str) -> None: + raise ValueError("non-finite provider number") + + +def _unavailable(request: SetupModelProxyRequest, code: str) -> SetupModelProxyReply: + return SetupModelProxyReply( + outcome="unavailable", + call_index=request.call_index, + status_code=None, + response_json=None, + response_headers=(), + failure_code=code, + session_exhausted=True, + ) + + +def _completed_reply( + request: SetupModelProxyRequest, + profile: ProjectModelProfile, + *, + status_code: int, + response_json: str, +) -> SetupModelProxyReply: + """Return a bounded SDK reply after the caller has retained and settled it.""" + try: + return SetupModelProxyReply( + outcome="completed", + call_index=request.call_index, + status_code=status_code, + response_json=response_json, + response_headers=(), + failure_code=None, + session_exhausted=request.call_index + 1 >= profile.spec.maximum_calls, + ) + except ValueError: + return _unavailable(request, "model_response_too_large") + + +__all__ = ( + "OpenAISetupProvider", + "SetupModelProxyHandler", + "SetupProviderResponse", + "SetupProviderUncertain", + "configured_setup_provider", +) diff --git a/backend/app/adapters/project_agents/setup_model_proxy.py b/backend/app/adapters/project_agents/setup_model_proxy.py new file mode 100644 index 000000000..46c38eec7 --- /dev/null +++ b/backend/app/adapters/project_agents/setup_model_proxy.py @@ -0,0 +1,357 @@ +"""Attempt-bound client for the trusted setup model dispatch listener.""" + +from __future__ import annotations + +import asyncio +import json +import os +import stat +import struct +from collections.abc import Callable, Mapping +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +from app.adapters.model_usage import model_profile_reader +from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration +from app.interfaces.setup_model_proxy import ( + SETUP_MODEL_PROXY_MAXIMUM_FRAME_BYTES, + SETUP_MODEL_PROXY_PROTOCOL, + SetupModelPhase, + SetupModelProxyReply, + SetupModelProxyRequest, + SetupModelProxySubject, + canonical_provider_json, +) +from app.modules.model_usage.api import ModelProfileSelector + + +class SetupModelProxyUnavailable(RuntimeError): + """The trusted dispatch did not produce a safely replayable provider response.""" + + +class SetupModelProxySession: + """Allocate monotonically increasing paid slots for one immutable setup attempt.""" + + def __init__( + self, + *, + socket_path: Path, + subject: SetupModelProxySubject, + maximum_calls: int, + maximum_output_tokens: int, + reasoning_effort: str, + text_verbosity: str, + timeout_seconds: float, + ) -> None: + if ( + not isinstance(socket_path, Path) + or type(subject) is not SetupModelProxySubject + or type(maximum_calls) is not int + or not 1 <= maximum_calls <= 100 + or type(maximum_output_tokens) is not int + or not 1 <= maximum_output_tokens <= 10_000_000 + or reasoning_effort + not in {"none", "minimal", "low", "medium", "high", "xhigh", "max"} + or text_verbosity not in {"low", "medium", "high"} + or type(timeout_seconds) is not float + or not 0.1 <= timeout_seconds <= 7_200.0 + ): + raise ValueError("setup model proxy session configuration is invalid") + self._socket_path = socket_path + self._subject = subject + self._maximum_calls = maximum_calls + self._maximum_output_tokens = maximum_output_tokens + self._reasoning_effort = reasoning_effort + self._text_verbosity = text_verbosity + self._timeout_seconds = timeout_seconds + self._call_index = 0 + self._blocked = False + self._active = False + + @property + def maximum_output_tokens(self) -> int: + return self._maximum_output_tokens + + @property + def reasoning_effort(self) -> str: + return self._reasoning_effort + + @property + def text_verbosity(self) -> str: + return self._text_verbosity + + async def dispatch( + self, phase: SetupModelPhase, provider_request: Mapping[str, object] + ) -> SetupModelProxyReply: + """Send one exact dispatch; any uncertain outcome permanently closes this session.""" + if self._blocked or self._call_index >= self._maximum_calls: + raise SetupModelProxyUnavailable("setup model session is unavailable") + if self._active: + self._blocked = True + raise SetupModelProxyUnavailable("setup model session is unavailable") + request = SetupModelProxyRequest( + subject=self._subject, + phase=phase, + call_index=self._call_index, + provider_request_json=canonical_provider_json(provider_request), + ) + self._active = True + try: + reply = await self._round_trip(request) + except BaseException: + self._blocked = True + raise + finally: + self._active = False + if reply.call_index != self._call_index: + self._blocked = True + raise SetupModelProxyUnavailable("setup model reply identity changed") + self._call_index += 1 + if reply.session_exhausted: + self._blocked = True + if reply.outcome != "completed": + self._blocked = True + raise SetupModelProxyUnavailable("setup model dispatch is unavailable") + return reply + + async def _round_trip(self, request: SetupModelProxyRequest) -> SetupModelProxyReply: + identity = _validated_socket(self._socket_path) + reader: asyncio.StreamReader | None = None + writer: asyncio.StreamWriter | None = None + try: + async with asyncio.timeout(self._timeout_seconds): + reader, writer = await asyncio.open_unix_connection(self._socket_path) + if _validated_socket(self._socket_path) != identity: + raise SetupModelProxyUnavailable("setup model proxy identity changed") + body = json.dumps( + request.wire_value(), + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + if not body or len(body) > SETUP_MODEL_PROXY_MAXIMUM_FRAME_BYTES: + raise SetupModelProxyUnavailable("setup model request exceeds transport bound") + writer.write(struct.pack(">I", len(body)) + body) + await writer.drain() + header = await reader.readexactly(4) + size = struct.unpack(">I", header)[0] + if not 0 < size <= SETUP_MODEL_PROXY_MAXIMUM_FRAME_BYTES: + raise SetupModelProxyUnavailable("setup model reply exceeds transport bound") + payload = await reader.readexactly(size) + if await reader.read(1): + raise SetupModelProxyUnavailable("setup model reply has trailing bytes") + return _decode_reply(payload) + except (OSError, TimeoutError, asyncio.IncompleteReadError, ValueError, UnicodeError) as exc: + raise SetupModelProxyUnavailable("setup model proxy is unavailable") from exc + finally: + if writer is not None: + writer.close() + try: + await asyncio.wait_for(writer.wait_closed(), timeout=1.0) + except (OSError, TimeoutError): + pass + + +def setup_model_session_factory(*, sessions, socket_path: Path | None): + """Bind exact attempt/profile facts before exposing a credential-free capability.""" + + @asynccontextmanager + async def open_session( + subject: SetupModelProxySubject | None, + configuration: ProjectGuideRuntimeConfiguration, + ): + if not configuration.uses_inference: + if subject is not None: + raise SetupModelProxyUnavailable("setup model subject is unexpected") + yield None + return + if socket_path is None or subject is None: + raise SetupModelProxyUnavailable("setup model proxy is unavailable") + if ( + configuration.model_profile_id is None + or configuration.model_profile_version is None + or configuration.model_profile_digest is None + ): + raise SetupModelProxyUnavailable("setup model profile is unavailable") + selector = ModelProfileSelector( + profile_id=configuration.model_profile_id, + profile_version=configuration.model_profile_version, + profile_digest=configuration.model_profile_digest, + ) + async with sessions() as database, database.begin(): + profile = await model_profile_reader(database).read_exact( + selector, + project_id=subject.project_id, + ) + if ( + profile.spec.provider, + profile.spec.api, + profile.spec.model_snapshot, + ) != ( + configuration.model_provider, + configuration.model_api, + configuration.model, + ): + raise SetupModelProxyUnavailable("setup model profile is unavailable") + if subject != SetupModelProxySubject( + compilation_attempt_id=subject.compilation_attempt_id, + project_id=subject.project_id, + guide_id=subject.guide_id, + source_snapshot_id=subject.source_snapshot_id, + setup_run_id=subject.setup_run_id, + setup_generation=subject.setup_generation, + runtime_configuration_hash=configuration.sha256, + profile_id=profile.profile_id, + profile_version=profile.spec.profile_version, + profile_digest=profile.profile_digest, + provider=profile.spec.provider, + api=profile.spec.api, + model=profile.spec.model_snapshot, + ): + raise SetupModelProxyUnavailable("setup model profile is unavailable") + yield SetupModelProxySession( + socket_path=socket_path, + subject=subject, + maximum_calls=profile.spec.maximum_calls, + maximum_output_tokens=profile.spec.maximum_output_tokens, + reasoning_effort=profile.spec.settings.reasoning_effort, + text_verbosity=profile.spec.settings.text_verbosity, + timeout_seconds=profile.spec.deadline_ms / 1000.0, + ) + + return open_session + + +def _decode_reply(payload: bytes) -> SetupModelProxyReply: + value = json.loads( + payload.decode("utf-8"), + parse_constant=_reject_constant, + object_pairs_hook=_closed_object, + ) + expected = { + "protocol_version", + "operation", + "outcome", + "call_index", + "status_code", + "response_json", + "response_headers", + "failure_code", + "session_exhausted", + } + if type(value) is not dict or set(value) != expected: + raise ValueError("setup model reply root is invalid") + if ( + value["protocol_version"] != SETUP_MODEL_PROXY_PROTOCOL + or value["operation"] != "dispatch" + or type(value["response_headers"]) is not dict + ): + raise ValueError("setup model reply protocol is invalid") + return SetupModelProxyReply( + outcome=value["outcome"], + call_index=value["call_index"], + status_code=value["status_code"], + response_json=value["response_json"], + response_headers=tuple(sorted(value["response_headers"].items())), + failure_code=value["failure_code"], + session_exhausted=value["session_exhausted"], + ) + + +def _validated_socket(path: Path) -> tuple[int, int]: + encoded = os.fsencode(path) + if not path.is_absolute() or not encoded or b"\0" in encoded or len(encoded) >= 108: + raise SetupModelProxyUnavailable("setup model proxy path is invalid") + metadata = os.lstat(path) + if ( + not stat.S_ISSOCK(metadata.st_mode) + or stat.S_ISLNK(metadata.st_mode) + or stat.S_IMODE(metadata.st_mode) != 0o600 + or metadata.st_uid != os.geteuid() + or metadata.st_gid != os.getegid() + ): + raise SetupModelProxyUnavailable("setup model proxy custody is invalid") + return metadata.st_dev, metadata.st_ino + + +def _closed_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise ValueError("duplicate setup model proxy field") + value[key] = item + return value + + +def _reject_constant(_: str) -> None: + raise ValueError("non-finite setup model proxy number") + + +__all__ = ( + "SetupModelProxySession", + "SetupModelProxyUnavailable", + "create_setup_httpx_client", + "setup_model_session_factory", +) + + +def create_setup_httpx_client( + session: SetupModelProxySession, + *, + model: str, + api: str, + phase: Callable[[], SetupModelPhase], + timeout_seconds: float, +): + """Build a no-network httpx2 client that preserves native SDK request parsing.""" + import httpx2 + + expected_path = { + "responses": "/v1/responses", + "chat_completions": "/v1/chat/completions", + }.get(api) + if expected_path is None: + raise ValueError("setup model API is unsupported") + + class DispatchTransport(httpx2.AsyncBaseTransport): + async def handle_async_request(self, request): + if ( + request.method != "POST" + or request.url.host != "model-proxy.invalid" + or request.url.path != expected_path + ): + raise httpx2.TransportError("setup model SDK request is outside policy") + body = await request.aread() + try: + value = json.loads( + body, + parse_constant=_reject_constant, + object_pairs_hook=_closed_object, + ) + except (ValueError, UnicodeError) as exc: + raise httpx2.TransportError("setup model SDK request is invalid") from exc + if ( + not isinstance(value, dict) + or value.get("model") != model + or value.get("stream", False) is not False + ): + raise httpx2.TransportError("setup model SDK request is outside policy") + try: + reply = await session.dispatch(phase(), value) + except SetupModelProxyUnavailable as exc: + raise httpx2.TransportError("setup model dispatch is unavailable") from exc + assert reply.status_code is not None and reply.response_json is not None + return httpx2.Response( + status_code=reply.status_code, + headers=dict(reply.response_headers), + content=reply.response_json.encode("utf-8"), + request=request, + ) + + return httpx2.AsyncClient( + transport=DispatchTransport(), + trust_env=False, + follow_redirects=False, + timeout=timeout_seconds, + ) diff --git a/backend/app/adapters/projects/__init__.py b/backend/app/adapters/projects/__init__.py index 8591c91bc..20dd101d4 100644 --- a/backend/app/adapters/projects/__init__.py +++ b/backend/app/adapters/projects/__init__.py @@ -1,58 +1,59 @@ """PROJECT-owned composition adapters.""" +from collections.abc import Callable +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.interfaces.external_services import ExternalServiceAdapterFactory +from app.interfaces.project_agents import ProjectGuideAgentRuntime +from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration +from app.modules.authorization.api import ( + ActorIdentityFacts, + ArtifactPolicyProjectionAuthorizationPort, + GuideSufficiencyProjectionAuthorizationPort, + ProjectGuideCompilationAuthorizationPort, + SetupFinalizationAuthorizationPort, +) +from app.modules.authorization.api.guide_proposal_review import GuideProposalAuthorizationPort +from app.modules.authorization.api.post_policy import PostPolicyAuthorizationPort +from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection +from app.modules.projects.api import ( + ProjectContributionPolicyEligibilityPort, + ProjectLockedPolicyContextPort, +) from app.modules.projects.api.guide_activation import ( GuideActivationAuthorizationPort, GuideActivationPort, GuideActivationReadinessPort, GuideContributionPolicyPort, ) -from app.modules.projects.api.guide_reads import ProjectGuideReadPort - - -from app.modules.projects.api.guide_documents import ProjectGuideDocumentScopePort -from app.modules.projects.api.guide_proposals import ( - GuideProposalOperationsPort, - GuideCorrectionDispatchPort, -) -from app.modules.authorization.api.guide_proposal_review import GuideProposalAuthorizationPort -from app.modules.authorization.api import ProjectGuideCompilationAuthorizationPort -from uuid import UUID -from collections.abc import Callable -from app.modules.authorization.api.post_policy import PostPolicyAuthorizationPort -from app.modules.projects.api.post_policy import PostPolicyOperationsPort, PostPolicyDeliveryPort -from app.modules.projects.post_policy.delivery import PostPolicyServiceAuthority -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from app.modules.projects.api.guide_compilation import ProjectGuideCompilationDeliveryPort from app.modules.projects.api.guide_documents import ( + GuideDocumentAccessFactory, GuideDocumentManifestPort, GuideDocumentToolsFactory, - GuideDocumentAccessFactory, GuideRuntimeResourceCleanupAdapter, + ProjectGuideDocumentScopePort, ) -from app.interfaces.project_agents import ProjectGuideAgentRuntime -from app.interfaces.external_services import ExternalServiceAdapterFactory -from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration -from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection -from app.modules.authorization.api import ( - ActorIdentityFacts, - GuideSufficiencyProjectionAuthorizationPort, - ArtifactPolicyProjectionAuthorizationPort, - SetupFinalizationAuthorizationPort, +from app.modules.projects.api.guide_proposals import ( + GuideCorrectionDispatchPort, + GuideProposalOperationsPort, +) +from app.modules.projects.api.guide_reads import ProjectGuideReadPort +from app.modules.projects.api.post_policy import PostPolicyDeliveryPort, PostPolicyOperationsPort +from app.modules.projects.contribution_policy import ProjectContributionPolicyEligibility +from app.modules.projects.guide_compilation.live import ( + LiveGuideCompilationCoordinator, + RequestAuthority, ) -from app.modules.projects.guide_compilation.live import RequestAuthority -from app.modules.projects.guide_compilation.orchestrator import GuideCompilationAuthorizationContext -from app.modules.projects.api.guide_compilation import ProjectGuideCompilationDeliveryPort -from app.modules.projects.guide_compilation.live import LiveGuideCompilationCoordinator from app.modules.projects.guide_compilation.orchestrator import ( + GuideCompilationAuthorizationContext, project_guide_compilation_execution_port, ) from app.modules.projects.guide_compilation.projections import GuideCompilationProjectionService - -from app.modules.projects.api import ( - ProjectContributionPolicyEligibilityPort, - ProjectLockedPolicyContextPort, -) -from app.modules.projects.contribution_policy import ProjectContributionPolicyEligibility from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository +from app.modules.projects.post_policy.delivery import PostPolicyServiceAuthority def project_locked_policy_context_port( @@ -69,15 +70,24 @@ def project_contribution_policy_eligibility_port( return ProjectContributionPolicyEligibility(session) +def setup_model_usage_owner(session: AsyncSession): + """Expose PROJECTS setup currentness only through the shared typed owner port.""" + from app.modules.projects.guide_compilation.model_usage import SetupModelUsageCurrent + + return SetupModelUsageCurrent(session) + + def project_guide_compilation_delivery_port( session_factory: async_sessionmaker[AsyncSession], *, material_factory: Callable[[AsyncSession], GuideDocumentManifestPort], document_tools_factory: GuideDocumentToolsFactory, + model_session_factory, checker_capabilities: ExternalCheckerCapabilityProjection, request_authority: RequestAuthority, execution_authority: GuideCompilationAuthorizationContext, configuration_factory: Callable[[], ProjectGuideRuntimeConfiguration], + model_profiles, runtime_factory: Callable[[ProjectGuideRuntimeConfiguration], ProjectGuideAgentRuntime], sufficiency_authorization_factory: Callable[ [AsyncSession], GuideSufficiencyProjectionAuthorizationPort @@ -96,6 +106,7 @@ def project_guide_compilation_delivery_port( checker_capabilities=checker_capabilities, request_authority=request_authority, configuration_factory=configuration_factory, + model_profiles=model_profiles, execution=project_guide_compilation_execution_port( session_factory, material_factory=material_factory, @@ -103,6 +114,7 @@ def project_guide_compilation_delivery_port( authorization_context=execution_authority, runtime_factory=runtime_factory, document_tools_factory=document_tools_factory, + model_session_factory=model_session_factory, ), projections=GuideCompilationProjectionService( session_factory, diff --git a/backend/app/interfaces/setup_model_proxy.py b/backend/app/interfaces/setup_model_proxy.py new file mode 100644 index 000000000..e0f4adbca --- /dev/null +++ b/backend/app/interfaces/setup_model_proxy.py @@ -0,0 +1,324 @@ +"""Closed private protocol for setup-agent provider dispatches.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import asdict, dataclass +from typing import Literal, Protocol +from uuid import UUID + +SETUP_MODEL_PROXY_PROTOCOL = "workstream.setup_model_proxy.v1" +SETUP_MODEL_PROXY_MAXIMUM_FRAME_BYTES = 1024 * 1024 +SetupModelPhase = Literal["draft", "review", "correction"] + + +def canonical_provider_json(value: Mapping[str, object]) -> str: + """Encode one provider request/response without duplicate or non-finite values.""" + if not isinstance(value, Mapping): + raise TypeError("setup model provider JSON is invalid") + encoded = json.dumps( + dict(value), + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ) + if not encoded or len(encoded.encode("utf-8")) > SETUP_MODEL_PROXY_MAXIMUM_FRAME_BYTES: + raise ValueError("setup model provider JSON is invalid") + return encoded + + +@dataclass(frozen=True, slots=True) +class SetupModelProxySubject: + """Exact setup attempt and locked model profile bound by trusted composition.""" + + compilation_attempt_id: UUID + project_id: UUID + guide_id: UUID + source_snapshot_id: UUID + setup_run_id: UUID + setup_generation: int + runtime_configuration_hash: str + profile_id: UUID + profile_version: int + profile_digest: str + provider: str + api: str + model: str + + def __post_init__(self) -> None: + if ( + any(type(value) is not UUID for value in ( + self.compilation_attempt_id, + self.project_id, + self.guide_id, + self.source_snapshot_id, + self.setup_run_id, + self.profile_id, + )) + or type(self.setup_generation) is not int + or self.setup_generation < 1 + or type(self.profile_version) is not int + or self.profile_version < 1 + or not _valid_sha256(self.runtime_configuration_hash) + or not _valid_sha256(self.profile_digest) + or any(not _valid_identifier(value) for value in (self.provider, self.api)) + or not _valid_model(self.model) + ): + raise ValueError("setup model proxy subject is invalid") + + +@dataclass(frozen=True, slots=True, repr=False) +class SetupModelProxyRequest: + """One actual provider dispatch, with no caller-controlled endpoint or credential.""" + + subject: SetupModelProxySubject + phase: SetupModelPhase + call_index: int + provider_request_json: str + + def __post_init__(self) -> None: + if ( + type(self.subject) is not SetupModelProxySubject + or self.phase not in {"draft", "review", "correction"} + or type(self.call_index) is not int + or not 0 <= self.call_index <= 99 + or type(self.provider_request_json) is not str + ): + raise ValueError("setup model proxy request is invalid") + try: + value = json.loads(self.provider_request_json) + except (TypeError, ValueError, UnicodeError): + raise ValueError("setup model proxy request is invalid") from None + if not isinstance(value, dict) or canonical_provider_json(value) != self.provider_request_json: + raise ValueError("setup model proxy request is invalid") + + @property + def request_sha256(self) -> str: + """Hash exact provider bytes independently of transport framing.""" + return "sha256:" + hashlib.sha256(self.provider_request_json.encode("utf-8")).hexdigest() + + def __repr__(self) -> str: + return "SetupModelProxyRequest()" + + def wire_value(self) -> dict[str, object]: + """Return the exact closed private wire root.""" + return { + "protocol_version": SETUP_MODEL_PROXY_PROTOCOL, + "operation": "dispatch", + **{ + key: str(value) if isinstance(value, UUID) else value + for key, value in asdict(self.subject).items() + }, + "phase": self.phase, + "call_index": self.call_index, + "provider_request_json": self.provider_request_json, + } + + +@dataclass(frozen=True, slots=True, repr=False) +class SetupModelProxyReply: + """Exact provider HTTP result or one closed infrastructure failure.""" + + outcome: Literal["completed", "unavailable"] + call_index: int + status_code: int | None + response_json: str | None + response_headers: tuple[tuple[str, str], ...] + failure_code: str | None + session_exhausted: bool + + def __post_init__(self) -> None: + completed = ( + self.outcome == "completed" + and type(self.status_code) is int + and 100 <= self.status_code <= 599 + and type(self.response_json) is str + and self.failure_code is None + ) + unavailable = ( + self.outcome == "unavailable" + and self.status_code is None + and self.response_json is None + and _valid_identifier(self.failure_code) + ) + if ( + type(self.call_index) is not int + or not 0 <= self.call_index <= 99 + or type(self.session_exhausted) is not bool + or type(self.response_headers) is not tuple + or not _valid_headers(self.response_headers) + or (not completed and not unavailable) + ): + raise ValueError("setup model proxy reply is invalid") + if completed: + try: + value = json.loads(self.response_json) + except (TypeError, ValueError, UnicodeError): + raise ValueError("setup model proxy reply is invalid") from None + if not isinstance(value, dict) or canonical_provider_json(value) != self.response_json: + raise ValueError("setup model proxy reply is invalid") + encoded = json.dumps( + self.wire_value(), + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + if not encoded or len(encoded) > SETUP_MODEL_PROXY_MAXIMUM_FRAME_BYTES: + raise ValueError("setup model proxy reply exceeds transport bound") + + def __repr__(self) -> str: + return f"SetupModelProxyReply(outcome={self.outcome!r}, )" + + def wire_value(self) -> dict[str, object]: + return { + "protocol_version": SETUP_MODEL_PROXY_PROTOCOL, + "operation": "dispatch", + "outcome": self.outcome, + "call_index": self.call_index, + "status_code": self.status_code, + "response_json": self.response_json, + "response_headers": dict(self.response_headers), + "failure_code": self.failure_code, + "session_exhausted": self.session_exhausted, + } + + +class SetupModelDispatchPort(Protocol): + """Attempt-bound capability used at each native SDK HTTP boundary.""" + + @property + def maximum_output_tokens(self) -> int: ... + + @property + def reasoning_effort(self) -> str: ... + + @property + def text_verbosity(self) -> str: ... + + async def dispatch( + self, phase: SetupModelPhase, provider_request: Mapping[str, object] + ) -> SetupModelProxyReply: ... + + +SetupModelProxyHandler = Callable[[SetupModelProxyRequest], Awaitable[SetupModelProxyReply]] + + +def setup_model_proxy_request_from_wire(value: object) -> SetupModelProxyRequest: + """Decode one already duplicate-checked JSON root through the closed contract.""" + expected = { + "protocol_version", + "operation", + "compilation_attempt_id", + "project_id", + "guide_id", + "source_snapshot_id", + "setup_run_id", + "setup_generation", + "runtime_configuration_hash", + "profile_id", + "profile_version", + "profile_digest", + "provider", + "api", + "model", + "phase", + "call_index", + "provider_request_json", + } + if ( + type(value) is not dict + or set(value) != expected + or value["protocol_version"] != SETUP_MODEL_PROXY_PROTOCOL + or value["operation"] != "dispatch" + ): + raise ValueError("setup model proxy request root is invalid") + try: + subject = SetupModelProxySubject( + compilation_attempt_id=UUID(value["compilation_attempt_id"]), + project_id=UUID(value["project_id"]), + guide_id=UUID(value["guide_id"]), + source_snapshot_id=UUID(value["source_snapshot_id"]), + setup_run_id=UUID(value["setup_run_id"]), + setup_generation=value["setup_generation"], + runtime_configuration_hash=value["runtime_configuration_hash"], + profile_id=UUID(value["profile_id"]), + profile_version=value["profile_version"], + profile_digest=value["profile_digest"], + provider=value["provider"], + api=value["api"], + model=value["model"], + ) + except (TypeError, ValueError, AttributeError): + raise ValueError("setup model proxy request root is invalid") from None + return SetupModelProxyRequest( + subject=subject, + phase=value["phase"], + call_index=value["call_index"], + provider_request_json=value["provider_request_json"], + ) + + +def _valid_sha256(value: object) -> bool: + return ( + type(value) is str + and len(value) == 71 + and value.startswith("sha256:") + and all(character in "0123456789abcdef" for character in value[7:]) + ) + + +def _valid_identifier(value: object) -> bool: + return ( + type(value) is str + and 1 <= len(value) <= 64 + and value[0].isascii() + and value[0].islower() + and all( + character.isascii() + and (character.islower() or character.isdigit() or character in {"_", ".", "-"}) + for character in value + ) + ) + + +def _valid_model(value: object) -> bool: + return ( + type(value) is str + and 1 <= len(value) <= 200 + and value[0].isalnum() + and value[0].isascii() + and all(character.isascii() and (character.isalnum() or character in "._:/-") for character in value) + ) + + +def _valid_headers(value: tuple[tuple[str, str], ...]) -> bool: + allowed = {"retry-after", "x-ratelimit-reset-requests", "x-ratelimit-reset-tokens"} + names = [name for name, _ in value] + return ( + names == sorted(names) + and len(names) == len(set(names)) + and all( + name in allowed + and type(item) is str + and len(item.encode("ascii", errors="ignore")) <= 256 + and all(32 <= ord(character) <= 126 for character in item) + for name, item in value + ) + ) + + +__all__ = ( + "SETUP_MODEL_PROXY_MAXIMUM_FRAME_BYTES", + "SETUP_MODEL_PROXY_PROTOCOL", + "SetupModelDispatchPort", + "SetupModelPhase", + "SetupModelProxyHandler", + "SetupModelProxyReply", + "SetupModelProxyRequest", + "SetupModelProxySubject", + "canonical_provider_json", + "setup_model_proxy_request_from_wire", +) diff --git a/backend/app/modules/model_usage/api.py b/backend/app/modules/model_usage/api.py index 33d6024b7..919dae9bf 100644 --- a/backend/app/modules/model_usage/api.py +++ b/backend/app/modules/model_usage/api.py @@ -21,7 +21,6 @@ from app.core.hashing import canonical_json_hash - Sha256 = Annotated[StrictStr, Field(pattern=r"^sha256:[0-9a-f]{64}$")] Identifier = Annotated[ StrictStr, Field(min_length=1, max_length=100, pattern=r"^[a-z][a-z0-9_.-]*$") @@ -898,7 +897,7 @@ class KnownNoEffectProof(ModelCallValue): class CompletedSetupModelUsageEffect(ModelCallValue): - """Exact retained setup response and once-settled project cost.""" + """Exact retained setup response and once-settled project cost or unusable result.""" effect: SetupModelEffectAuthorityFacts provider_response_id: ProviderResponseIdentifier @@ -907,7 +906,7 @@ class CompletedSetupModelUsageEffect(ModelCallValue): usage: ModelCallUsage settled_nanodollars: Nanodollars completed_at: AwareDatetime - state: Literal["completed"] = "completed" + state: Literal["completed", "known_failed"] = "completed" replayed: bool @@ -969,7 +968,7 @@ class ModelInvocationGrantFacts(ModelCallValue): class ModelProfileReaderPort(Protocol): - """Read one immutable profile by complete identity.""" + """Read immutable project profiles without mutation authority.""" async def read_exact( self, @@ -980,6 +979,17 @@ async def read_exact( """Resolve the complete selector only for its exact project.""" ... + async def read_latest_matching( + self, + *, + project_id: UUID, + provider: str, + api: str, + model_snapshot: str, + ) -> ProjectModelProfile: + """Select the highest published version matching one configured identity.""" + ... + class ModelCallAdministrationPort(Protocol): """Own exact public profile, budget and recovery-child mutations.""" @@ -1130,67 +1140,67 @@ def model_action_digest(action: str, facts: object) -> str: __all__ = ( - "CompletedSetupModelUsageEffect", - "CompletedModelCall", "AttachedModelCallAttempt", + "CompletedModelCall", + "CompletedSetupModelUsageEffect", + "KnownFailedModelCall", "KnownNoEffectProof", "KnownNoEffectSetupModelUsageEffect", - "KnownFailedModelCall", "ModelActionEvidence", "ModelBudgetProvisioningFacts", - "ModelCallAuthorityPort", "ModelCallAdministrationPort", + "ModelCallAuthorityPort", "ModelCallBudgetUnavailable", "ModelCallConflict", "ModelCallCurrentExecutionPort", - "ModelCallEvidence", "ModelCallEffectAuthorityFacts", + "ModelCallEvidence", "ModelCallEvidencePort", "ModelCallExecutionFacts", + "ModelCallRecoveryAttachment", "ModelCallUnavailable", "ModelCallUsage", - "ModelCallRecoveryAttachment", "ModelDispatchReservation", - "ModelGenerationSettings", "ModelEvidenceCustodyPort", "ModelEvidenceReservation", "ModelEvidenceSelector", + "ModelGenerationSettings", "ModelInvocationGrantAuthorityPort", "ModelInvocationGrantFacts", "ModelPaidSlotKey", "ModelPricing", "ModelProfileProvisioningFacts", + "ModelProfileReaderPort", + "ModelProfileSelector", "ModelRecoveryCandidate", "ModelRecoveryChildRequest", "ModelRecoveryOwnerFacts", "ModelRecoverySourcePort", - "ModelProfileReaderPort", - "ModelProfileSelector", "ObservedSetupModelUsageEffect", "PreparedModelCallAttempt", + "PreparedSetupModelUsageEffect", "ProjectModelBudgetConfiguration", "ProjectModelBudgetConfigurationSpec", "ProjectModelProfile", "ProjectModelProfileSpec", - "PreparedSetupModelUsageEffect", - "SetupModelDispatchReservation", - "SetupModelOutputReservation", - "SetupModelOutputReservationPort", "SetupModelArtifactOutputPort", + "SetupModelDispatchReservation", + "SetupModelEffectAuthorityFacts", "SetupModelOutputArtifactRequest", "SetupModelOutputArtifactResult", "SetupModelOutputBindingPort", "SetupModelOutputBindingRequest", "SetupModelOutputBindingResult", + "SetupModelOutputReservation", + "SetupModelOutputReservationPort", "SetupModelOutputSelector", "SetupModelOutputSlot", - "SetupModelEffectAuthorityFacts", + "SetupModelOwnerProfile", "SetupModelUsageAuthorityPort", "SetupModelUsageEvidencePort", - "SetupModelOwnerProfile", "SetupModelUsageOwnerPort", "SetupModelUsageSlotKey", - "model_usage_cost", "model_action_digest", "model_retry_terminal_digest", + "model_usage_cost", ) diff --git a/backend/app/modules/model_usage/profiles.py b/backend/app/modules/model_usage/profiles.py index c1cfbe3fe..0c52a4260 100644 --- a/backend/app/modules/model_usage/profiles.py +++ b/backend/app/modules/model_usage/profiles.py @@ -103,5 +103,30 @@ async def read_exact( raise ModelCallUnavailable("model_profile_unavailable") return stored_model_profile(row) + async def read_latest_matching( + self, + *, + project_id: UUID, + provider: str, + api: str, + model_snapshot: str, + ) -> ProjectModelProfile: + """Select only the newest immutable version for this project and identity.""" + self._require_root_transaction() + row = await self._session.scalar( + select(ModelUsageProfile) + .where( + ModelUsageProfile.project_id == str(project_id), + ModelUsageProfile.provider == provider, + ModelUsageProfile.api == api, + ModelUsageProfile.model_snapshot == model_snapshot, + ) + .order_by(ModelUsageProfile.profile_version.desc()) + .limit(1) + ) + if row is None: + raise ModelCallUnavailable("model_profile_unavailable") + return stored_model_profile(row) + __all__ = ("ModelProfileReader", "stored_model_profile") diff --git a/backend/app/modules/model_usage/setup.py b/backend/app/modules/model_usage/setup.py index 767f539ec..03868c6bb 100644 --- a/backend/app/modules/model_usage/setup.py +++ b/backend/app/modules/model_usage/setup.py @@ -16,8 +16,8 @@ ModelCallBudgetUnavailable, ModelCallConflict, ModelCallEvidence, - ModelCallUsage, ModelCallUnavailable, + ModelCallUsage, ObservedSetupModelUsageEffect, PreparedSetupModelUsageEffect, SetupModelDispatchReservation, @@ -204,6 +204,31 @@ async def prepare( await self._repo.add(effect) return _prepared(envelope, slot, effect, key, replayed=False) + async def recover_completed( + self, key: SetupModelUsageSlotKey, request_sha256: str + ) -> CompletedSetupModelUsageEffect | None: + """Return one exact completed response without recreating provider I/O.""" + require_model_call_transaction(self._session) + key = SetupModelUsageSlotKey.model_validate(key) + owner_profile = await self._owner.require_setup_current(key) + await self._profile(key, owner_profile) + await self._repo.lock_setup_envelope_scope(key) + envelope = await self._repo.setup_envelope(key, lock=True) + if envelope is None: + return None + await self._repo.lock_setup_slot_scope(key) + slot = await self._repo.setup_slot(key, lock=True) + if slot is None: + return None + if slot.envelope_id != envelope.id or slot.request_sha256 != request_sha256: + raise ModelCallConflict("setup_model_request_conflict") + effect = await self._repo.latest_setup_effect(slot.id, lock=True) + if effect is None or effect.state == "prepared": + return None + if effect.state != "completed": + raise ModelCallUnavailable("setup_model_effect_not_replayable") + return self._completed(effect, slot, replayed=True) + async def reserve_dispatch( self, prepared: PreparedSetupModelUsageEffect, @@ -378,6 +403,7 @@ async def complete( provider_model_snapshot: str, response_evidence: ModelCallEvidence, usage: ModelCallUsage, + usable: bool = True, ) -> CompletedSetupModelUsageEffect: """Settle one verified setup response under narrow retained-effect authority.""" require_model_call_transaction(self._session) @@ -394,6 +420,7 @@ async def complete( "provider_model_snapshot": provider_model_snapshot, "response_evidence": response_evidence.model_dump(mode="json"), "usage": usage.model_dump(mode="json"), + "usable": usable, } ) authority = await self._authority.authorize_setup_complete( @@ -418,8 +445,11 @@ async def complete( cost = model_usage_cost(profile.spec, usage) row = await self._lock_terminal(slot, supplied.effect_id) _require_effect(supplied, slot, row) - if row.state == "completed": + target_state = "completed" if usable else "known_failed" + if row.state in {"completed", "known_failed"}: if ( + row.state != target_state + or row.provider_response_id != provider_response_id or _evidence(row, "response") != response_evidence or _usage(row) != usage @@ -437,7 +467,7 @@ async def complete( row.settled_nanodollars = cost row.terminal_authorization_evidence_id = str(authority.evidence_id) row.terminal_at = await self._repo.now() - row.state = "completed" + row.state = target_state await self._repo.flush() return self._completed(row, slot, replayed=False) @@ -568,6 +598,7 @@ def _completed( usage=usage, settled_nanodollars=row.settled_nanodollars, completed_at=row.terminal_at, + state=row.state, replayed=replayed, ) diff --git a/backend/app/modules/projects/api/guide_documents.py b/backend/app/modules/projects/api/guide_documents.py index cbd9146f4..48b5b60e7 100644 --- a/backend/app/modules/projects/api/guide_documents.py +++ b/backend/app/modules/projects/api/guide_documents.py @@ -2,18 +2,20 @@ from __future__ import annotations +import hashlib from contextlib import AbstractAsyncContextManager from dataclasses import dataclass from datetime import datetime from typing import BinaryIO, Literal, Protocol from uuid import UUID -import hashlib from pydantic import BaseModel, ConfigDict, Field, StrictInt, model_validator from app.core.hashing import canonical_json_hash from app.interfaces.external_services import ExternalServiceAdapter from app.interfaces.guide_document_tools import GuideDocumentMediaType, GuideDocumentToolPort +from app.interfaces.setup_model_proxy import SetupModelDispatchPort + DOCUMENT_EXTENSIONS: dict[str, str] = { "application/pdf": "pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", @@ -212,6 +214,7 @@ class GuideRuntimeCapabilities: """The sole non-serializable capability issued to an agent runtime.""" tools: GuideDocumentToolPort + model: SetupModelDispatchPort | None = None class GuideDocumentAccessFactory(Protocol): diff --git a/backend/app/modules/projects/guide_compilation/live.py b/backend/app/modules/projects/guide_compilation/live.py index e66df72db..7c679aa69 100644 --- a/backend/app/modules/projects/guide_compilation/live.py +++ b/backend/app/modules/projects/guide_compilation/live.py @@ -7,40 +7,42 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from app.modules.projects.api.guide_documents import GuideDocumentManifestPort -from app.modules.projects.api.task_examples import ( - GuideTaskExampleInputError, - require_task_example_commitment, -) from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration from app.modules.authorization.api import ( ActorIdentityFacts, ProjectGuideCompilationAuthorizationPort, ) from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection +from app.modules.model_usage.api import ModelProfileReaderPort from app.modules.projects.api.guide_compilation import ( ProjectGuideCompilationDelivery, ProjectGuideCompilationDeliveryError, + ProjectGuideCompilationExecutionClassification, ProjectGuideCompilationExecutionCommand, ProjectGuideCompilationExecutionPort, - ProjectGuideCompilationExecutionClassification, ProjectGuideSetupFinalizationCommand, ) from app.modules.projects.api.guide_compilation_projections import ProjectGuideProjectionCommand +from app.modules.projects.api.guide_documents import GuideDocumentManifestPort from app.modules.projects.api.setup_identity import project_guide_compilation_task_id -from app.modules.projects.repository import ProjectRepository +from app.modules.projects.api.task_examples import ( + GuideTaskExampleInputError, + require_task_example_commitment, +) from app.modules.projects.models import ProjectSetupRun -from .source_state import is_compilation_source_setup +from app.modules.projects.repository import ProjectRepository + from .diagnostics import compilation_setup_response -from .request_inputs import CompilationRequestInputs, automatic_request_selector +from .finalization import GuideCompilationFinalizationService from .models import ( - ProjectGuideCompilationAttempt, ProjectGuideCompilation, + ProjectGuideCompilationAttempt, ProjectGuideSetupFinalization, ) from .repository import GuideCompilationIntegrityError +from .request_inputs import CompilationRequestInputs, automatic_request_selector from .service import GuideCompilationService -from .finalization import GuideCompilationFinalizationService +from .source_state import is_compilation_source_setup RequestAuthority = Callable[ [AsyncSession, UUID], @@ -61,6 +63,7 @@ def __init__( checker_capabilities: ExternalCheckerCapabilityProjection, request_authority: RequestAuthority, configuration_factory: Callable[[], ProjectGuideRuntimeConfiguration], + model_profiles: Callable[[AsyncSession], ModelProfileReaderPort], execution: ProjectGuideCompilationExecutionPort, projections, finalization_authorization_factory, @@ -70,6 +73,7 @@ def __init__( self._checker_capabilities = checker_capabilities self._request_authority = request_authority self._configuration = configuration_factory + self._model_profiles = model_profiles self._execution = execution self._projections = projections self._finalization_authorization = finalization_authorization_factory @@ -88,11 +92,7 @@ async def run(self, delivery: ProjectGuideCompilationDelivery) -> dict: return await self._finalize(finalization) if has_attempt and snapshot is None: raise GuideCompilationIntegrityError("compilation runtime configuration unavailable") - configuration = ( - ProjectGuideRuntimeConfiguration.model_validate(snapshot) - if snapshot is not None - else self._configuration() - ) + configuration = ProjectGuideRuntimeConfiguration.model_validate(snapshot) if manual_attempt is None: operation_id = automatic_request_selector( delivery.setup_run_id, delivery.setup_generation @@ -232,13 +232,40 @@ async def _admit(self, delivery: ProjectGuideCompilationDelivery): from .delivery_request import manual_delivery_attempt manual_attempt = await manual_delivery_attempt(session, setup, attempt) + runtime_configuration = ( + attempt.runtime_configuration + if attempt is not None + else (await self._select_runtime_configuration(session, delivery.project_id)) + .model_dump(mode="json") + ) return ( None, attempt is not None, - attempt.runtime_configuration if attempt else None, + runtime_configuration, manual_attempt, ) + async def _select_runtime_configuration( + self, session: AsyncSession, project_id: UUID + ) -> ProjectGuideRuntimeConfiguration: + """Snapshot the newest exact Operator profile while PROJECTS custody is held.""" + configuration = self._configuration() + if not configuration.uses_inference: + return configuration + profile = await self._model_profiles(session).read_latest_matching( + project_id=project_id, + provider=configuration.model_provider, + api=configuration.model_api, + model_snapshot=configuration.model, + ) + return configuration.model_copy( + update={ + "model_profile_id": profile.profile_id, + "model_profile_version": profile.spec.profile_version, + "model_profile_digest": profile.profile_digest, + } + ) + async def _finalize(self, command: ProjectGuideSetupFinalizationCommand) -> dict: """Create or replay the existing finalizer's receipt under fresh authority.""" async with self._sessions() as session, session.begin(): diff --git a/backend/app/modules/projects/guide_compilation/model_usage.py b/backend/app/modules/projects/guide_compilation/model_usage.py index aea8859e7..60a2c9978 100644 --- a/backend/app/modules/projects/guide_compilation/model_usage.py +++ b/backend/app/modules/projects/guide_compilation/model_usage.py @@ -32,7 +32,7 @@ async def require_setup_current(self, key: SetupModelUsageSlotKey) -> SetupModel ProjectGuideCompilationAttempt.setup_generation == key.setup_generation, ProjectGuideCompilationAttempt.runtime_configuration_hash == key.runtime_configuration_hash, - ProjectGuideCompilationAttempt.status == "compilation_reserved", + ProjectGuideCompilationAttempt.status == "compilation_provider_uncertain", ) .with_for_update() .execution_options(populate_existing=True) diff --git a/backend/app/modules/projects/guide_compilation/orchestrator.py b/backend/app/modules/projects/guide_compilation/orchestrator.py index 22704aa7e..e0fa78662 100644 --- a/backend/app/modules/projects/guide_compilation/orchestrator.py +++ b/backend/app/modules/projects/guide_compilation/orchestrator.py @@ -12,21 +12,23 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - -from app.modules.projects.api.guide_documents import ( - GuideDocumentManifestPort, - GuideDocumentUnavailable, -) +from app.interfaces.external_services import ExternalServiceAdapterError from app.interfaces.project_agents import ( + ProjectAgentRuntimeError, ProjectGuideAgentRuntime, ProjectGuideCompilationContext, ProjectGuideCompilationInvalidOutputError, ProjectGuideCompilationResult, - require_complete_project_guide_compilation_result, + project_guide_compilation_prompt_bytes, require_complete_generated_specifications, + require_complete_project_guide_compilation_result, validate_project_guide_compilation_result, ) -from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection +from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration +from app.interfaces.setup_model_proxy import ( + SetupModelDispatchPort, + SetupModelProxySubject, +) from app.modules.authorization.api import ( ActorIdentityFacts, AuthorizationDenied, @@ -34,6 +36,7 @@ PreparedAuthorizationInvalid, ProjectGuideCompilationAuthorizationPort, ) +from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection from app.modules.projects.api import ( ProjectGuideCompilationExecutionClassification, ProjectGuideCompilationExecutionCommand, @@ -41,6 +44,12 @@ ProjectGuideCompilationExecutionPort, ProjectGuideCompilationExecutionResult, ) +from app.modules.projects.api.guide_documents import ( + GuideDocumentManifestPort, + GuideDocumentToolsFactory, + GuideDocumentUnavailable, + GuideRuntimeCapabilities, +) from .context import build_project_guide_compilation_context from .contracts import ( @@ -56,16 +65,6 @@ GuideCompilationService, load_compilation_execution_state, ) -from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration -from app.modules.projects.api.guide_documents import ( - GuideDocumentToolsFactory, - GuideRuntimeCapabilities, -) -from app.interfaces.external_services import ExternalServiceAdapterError -from app.interfaces.project_agents import ( - ProjectAgentRuntimeError, - project_guide_compilation_prompt_bytes, -) class GuideCompilationAuthorizationContext(Protocol): @@ -95,6 +94,10 @@ def __init__( checker_capabilities: ExternalCheckerCapabilityProjection, authorization_context: GuideCompilationAuthorizationContext, document_tools_factory: GuideDocumentToolsFactory, + model_session_factory: Callable[ + [SetupModelProxySubject | None, ProjectGuideRuntimeConfiguration], + AbstractAsyncContextManager[SetupModelDispatchPort | None], + ] | None = None, ) -> None: """Store the owner-supplied ports used by each short transaction.""" self._session_factory = session_factory @@ -102,16 +105,26 @@ def __init__( self._checker_capabilities = checker_capabilities self._authorization_context = authorization_context self._document_tools_factory = document_tools_factory + self._model_session_factory = model_session_factory or _no_model_session + self._model_session_configured = model_session_factory is not None @asynccontextmanager async def capabilities(self, state, context): """Issue resources only from the orchestrator's winning dispatch branch.""" - async with self._document_tools_factory( - state.preflight_facts.attempt_id, - context.material, - context.runtime_configuration, - ) as tools: - yield GuideRuntimeCapabilities(tools=tools) + model_subject = ( + _setup_model_subject(state, context.runtime_configuration) + if self._model_session_configured + else None + ) + async with ( + self._document_tools_factory( + state.preflight_facts.attempt_id, + context.material, + context.runtime_configuration, + ) as tools, + self._model_session_factory(model_subject, context.runtime_configuration) as model, + ): + yield GuideRuntimeCapabilities(tools=tools, model=model) async def load(self, attempt_id: UUID) -> CompilationExecutionState: """Load one exact attempt and translate storage failures safely.""" @@ -225,6 +238,10 @@ def project_guide_compilation_execution_port( authorization_context: GuideCompilationAuthorizationContext, runtime_factory: Callable[[ProjectGuideRuntimeConfiguration], ProjectGuideAgentRuntime], document_tools_factory: GuideDocumentToolsFactory, + model_session_factory: Callable[ + [SetupModelProxySubject | None, ProjectGuideRuntimeConfiguration], + AbstractAsyncContextManager[SetupModelDispatchPort | None], + ] | None = None, ) -> ProjectGuideCompilationExecutionPort: """Compose the hidden port from existing owner-supplied dependencies.""" backend = SqlAlchemyGuideCompilationExecutionBackend( @@ -233,10 +250,47 @@ def project_guide_compilation_execution_port( checker_capabilities=checker_capabilities, authorization_context=authorization_context, document_tools_factory=document_tools_factory, + model_session_factory=model_session_factory, ) return GuideCompilationOrchestrator(backend, runtime_factory) +@asynccontextmanager +async def _no_model_session(_subject, _configuration): + """Keep provider-free and isolated owner tests free of production composition.""" + yield None + + +def _setup_model_subject( + state: CompilationExecutionState, + configuration: ProjectGuideRuntimeConfiguration, +) -> SetupModelProxySubject | None: + """Project exact attempt/profile lineage into the shared model capability.""" + if not configuration.uses_inference: + return None + if ( + configuration.model_profile_id is None + or configuration.model_profile_version is None + or configuration.model_profile_digest is None + ): + raise ProjectGuideCompilationExecutionError("runtime_unavailable") + return SetupModelProxySubject( + compilation_attempt_id=state.preflight_facts.attempt_id, + project_id=state.identity.project_id, + guide_id=state.identity.guide_id, + source_snapshot_id=state.identity.source_snapshot_id, + setup_run_id=state.identity.setup_run_id, + setup_generation=state.identity.setup_generation, + runtime_configuration_hash=configuration.sha256, + profile_id=configuration.model_profile_id, + profile_version=configuration.model_profile_version, + profile_digest=configuration.model_profile_digest, + provider=configuration.model_provider, + api=configuration.model_api, + model=configuration.model, + ) + + class GuideCompilationExecutionBackend(Protocol): """Exact persistence/context operations required by the state machine.""" @@ -287,6 +341,7 @@ async def execute( ) -> ProjectGuideCompilationExecutionResult: """Execute or safely recover one previously authorized attempt.""" state = await self._backend.load(command.attempt_id) + resuming_metered = _metered_setup_resume(state) recovered = await self._recover(state) if recovered is not None: return recovered @@ -310,8 +365,8 @@ async def execute( await runtime.aclose() raise ProjectGuideCompilationExecutionError("runtime_unavailable") from None try: - dispatch = await self._backend.fence(state) - if not dispatch.dispatch_permitted: + dispatch = None if resuming_metered else await self._backend.fence(state) + if dispatch is not None and not dispatch.dispatch_permitted: raced_state = await self._backend.load(command.attempt_id) recovered = await self._recover(raced_state) if recovered is None: @@ -353,7 +408,9 @@ async def execute( # No model call exists to be uncertain about; reopen and retry. await self._backend.release_no_inference(state) raise ProjectGuideCompilationExecutionError("runtime_unavailable") from None - return _receipt_result(dispatch) + if dispatch is not None: + return _receipt_result(dispatch) + return _state_result(await self._backend.load(command.attempt_id)) if terminal_receipt is not None: return _receipt_result(terminal_receipt) return _receipt_result(await self._backend.persist(state, context)) @@ -373,6 +430,10 @@ async def _recover( stale_before = datetime.now(UTC) - timedelta(seconds=configuration.timeout_seconds) if await self._backend.release_no_inference(state, stale_before=stale_before): return None + if state.classification is CompilationRecoveryClassification.PROVIDER_UNCERTAIN and ( + _metered_setup_resume(state) + ): + return None if state.classification in { CompilationRecoveryClassification.PERSISTED, CompilationRecoveryClassification.INVALID_TERMINAL, @@ -385,6 +446,19 @@ async def _recover( return None +def _metered_setup_resume(state: CompilationExecutionState) -> bool: + """Resume only attempts whose every provider effect is guarded by MODEL-USAGE.""" + configuration = state.runtime_configuration + return ( + state.classification is CompilationRecoveryClassification.PROVIDER_UNCERTAIN + and configuration is not None + and configuration.uses_inference + and configuration.model_profile_id is not None + and configuration.model_profile_version is not None + and configuration.model_profile_digest is not None + ) + + def _require_valid_result( context: ProjectGuideCompilationContext, result: ProjectGuideCompilationResult, diff --git a/backend/app/workers/project_setup.py b/backend/app/workers/project_setup.py index 1b187a7ee..f83326aaa 100644 --- a/backend/app/workers/project_setup.py +++ b/backend/app/workers/project_setup.py @@ -1,38 +1,40 @@ """Celery composition for automatic unified project-guide compilation.""" -from celery.utils.log import get_task_logger from functools import partial from pathlib import Path + +from celery.utils.log import get_task_logger from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from app.adapters.artifacts import guide_document_access_runtime, guide_document_manifest_port +from app.adapters.auth import ( + artifact_policy_projection_authorization, + guide_compilation_execution_authority, + guide_compilation_request_authority, + guide_sufficiency_projection_authorization, + setup_finalization_authorization, +) +from app.adapters.checkers import external_checker_capability_projection, model_profile_reader +from app.adapters.guide_document_tools import ( + GUIDE_DOCUMENT_PARSER_PROVIDER_KEY, + guide_document_parser_factory, +) from app.adapters.project_agents import ( RETAINED_OPENAI_GUIDE_RUNTIME_KEY, + build_project_guide_adapter_profile, create_guide_runtime_cleanup_factory, create_project_guide_runtime, - build_project_guide_adapter_profile, ) -from app.adapters.guide_document_tools import ( - GUIDE_DOCUMENT_PARSER_PROVIDER_KEY, - guide_document_parser_factory, +from app.adapters.project_agents.setup_model_proxy import setup_model_session_factory +from app.adapters.projects import ( + project_guide_compilation_delivery_port, + project_guide_document_tools_factory, ) from app.core.config import get_settings from app.core.project_agents import ( project_guide_runtime_configuration, ) from app.db.session import get_database_url -from app.adapters.auth import ( - guide_compilation_request_authority, - guide_compilation_execution_authority, - guide_sufficiency_projection_authorization, - artifact_policy_projection_authorization, - setup_finalization_authorization, -) -from app.adapters.projects import ( - project_guide_compilation_delivery_port, - project_guide_document_tools_factory, -) -from app.adapters.checkers import external_checker_capability_projection -from app.adapters.artifacts import guide_document_manifest_port, guide_document_access_runtime from app.modules.projects.api.guide_compilation import ( ProjectGuideCompilationDelivery, ProjectGuideCompilationDeliveryError, @@ -64,6 +66,10 @@ async def _coordinator(session_factory): timeout_seconds=30.0, ).create(GUIDE_DOCUMENT_PARSER_PROVIDER_KEY), ), + model_session_factory=setup_model_session_factory( + sessions=session_factory, + socket_path=settings.checker_model_proxy_socket, + ), checker_capabilities=capabilities, request_authority=guide_compilation_request_authority, execution_authority=guide_compilation_execution_authority, @@ -73,6 +79,7 @@ async def _coordinator(session_factory): build_project_guide_adapter_profile, settings.project_agent_runtime ), ), + model_profiles=model_profile_reader, runtime_factory=create_project_guide_runtime, sufficiency_authorization_factory=guide_sufficiency_projection_authorization, policy_authorization_factory=artifact_policy_projection_authorization, diff --git a/backend/tests/checkers/external/test_harnessy_proof_checker.py b/backend/tests/checkers/external/test_harnessy_proof_checker.py index 45b39d63c..f1cd2cd8b 100644 --- a/backend/tests/checkers/external/test_harnessy_proof_checker.py +++ b/backend/tests/checkers/external/test_harnessy_proof_checker.py @@ -210,11 +210,16 @@ def test_published_service_paths_match_the_compose_overlay() -> None: "/var/run/docker.sock" not in str(mount) for mount in overlay["services"].get(name, {}).get("volumes", ()) ) - for name in ("backend", "worker", "beat"): + for name in ("backend", "beat"): assert all( "checker_model_proxy" not in str(mount) for mount in overlay["services"][name]["volumes"] ) + worker = overlay["services"]["worker"] + assert worker["environment"]["WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET"] == ( + "/run/ws-model-proxy/model-proxy.sock" + ) + assert "checker_model_proxy:/run/ws-model-proxy:ro" in worker["volumes"] assert "checker_model_proxy:/run/ws-model-proxy:ro" in overlay["services"]["checker"]["volumes"] assert ( "checker_model_relay:" diff --git a/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py index cf21fb923..231cfb404 100644 --- a/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py @@ -1,21 +1,22 @@ """Focused setup and shared-budget PostgreSQL model-usage proofs.""" +from app.modules.model_usage.models import ModelUsageEffect from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( AsyncSession, BoundaryPorts, - DBAPIError, DatabaseFacts, + DBAPIError, ModelCallBudgetUnavailable, ModelCallConflict, ModelCallUsage, ModelGenerationSettings, ModelProfileProvisioningFacts, + ModelProfileReader, ModelUsageBudgetAccount, asyncio, canonical_json_hash, evidence, execution, - model_database as _model_database, new_record_id, provision, pytest, @@ -27,7 +28,9 @@ text, uuid4, ) -from app.modules.model_usage.models import ModelUsageEffect +from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( + model_database as _model_database, +) model_database = _model_database @@ -61,6 +64,67 @@ async def test_profile_publication_is_not_owned_by_a_checker_registry_entry( assert profile.spec.project_id == model_database.project_id +async def test_setup_profile_selection_pins_latest_matching_identity( + model_database: DatabaseFacts, +) -> None: + """New attempts see a matching update; old snapshots and other models do not drift.""" + ports = BoundaryPorts() + first = await provision(model_database, ports, limit=10_000) + + async def publish(spec): + facts = ModelProfileProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=spec, + profile_digest=canonical_json_hash(spec.model_dump(mode="json")), + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await service(session, ports).provision_profile(facts) + + second = await publish( + first.spec.model_copy( + update={ + "profile_version": 2, + "settings": ModelGenerationSettings( + reasoning_effort="high", text_verbosity="low" + ), + } + ) + ) + await publish( + first.spec.model_copy( + update={ + "profile_version": 3, + "model_snapshot": "gpt-unrelated-2026-10-01", + } + ) + ) + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + reader = ModelProfileReader(session) + selected = await reader.read_latest_matching( + project_id=model_database.project_id, + provider=first.spec.provider, + api=first.spec.api, + model_snapshot=first.spec.model_snapshot, + ) + pinned = await reader.read_exact( + { + "profile_id": first.profile_id, + "profile_version": first.spec.profile_version, + "profile_digest": first.profile_digest, + }, + project_id=model_database.project_id, + ) + + assert selected.profile_id == second.profile_id + assert selected.spec.profile_version == 2 + assert pinned.profile_id == first.profile_id + assert pinned.spec.profile_version == 1 + + async def test_database_guards_recompute_profile_and_budget_digests( model_database: DatabaseFacts, ) -> None: diff --git a/backend/tests/checkers_model_calls/test_proxy_orchestration.py b/backend/tests/checkers_model_calls/test_proxy_orchestration.py index 8a2ba0e9f..440ad2165 100644 --- a/backend/tests/checkers_model_calls/test_proxy_orchestration.py +++ b/backend/tests/checkers_model_calls/test_proxy_orchestration.py @@ -1,14 +1,15 @@ """Cross-port call sequencing; PostgreSQL/ART custody have separate integration proof.""" -from contextlib import asynccontextmanager -from datetime import UTC, datetime, timedelta import hashlib import json +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta from types import SimpleNamespace from uuid import uuid4 import pytest +from app.adapters.checkers.model_proxy import CheckerModelProxy from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id from app.interfaces.checker_model_provider import ( @@ -33,7 +34,6 @@ ModelPaidSlotKey, ProjectModelProfile, ) -from app.adapters.checkers.model_proxy import CheckerModelProxy from tests.checkers_model_calls.test_model_call_contract import profile_spec, sha @@ -370,6 +370,35 @@ async def test_missing_usage_retains_uncertain_effect_for_later_recovery(): assert h.create_count == h.settle_count == 1 +@pytest.mark.parametrize("extra, expected", [("", "completed"), ("a", "unavailable")]) +async def test_output_byte_bound_settles_incurred_multibyte_response(extra, expected): + h = Harness() + output = "£" * (h.profile.spec.maximum_output_bytes // 2) + extra + h.observation = CheckerModelObservation( + reference=h.reference, + provider_model=h.profile.spec.model_snapshot, + status=CheckerModelProviderStatus.COMPLETED, + output_text=output, + usage=CheckerModelUsage(20, 5, 10, 2, 30), + ) + h.create_result = CheckerModelCreateResult( + CheckerModelCreateOutcome.ACCEPTED, + observation=h.observation, + known_response=h.reference, + ) + + reply = await h.invoke() + + assert reply.outcome == expected + assert h.create_count == h.settle_count == 1 + assert h.state == ("known_failed" if extra else "completed") + assert "model_response" in h.blobs + if extra: + assert reply.failure_code == "model_response_too_large" + assert (await h.invoke()).outcome == "unavailable" + assert h.create_count == h.settle_count == 1 + + async def test_stale_invocation_cannot_read_a_completed_effect(): h = Harness() h.stale = True diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler.py b/backend/tests/checkers_model_calls/test_setup_model_handler.py new file mode 100644 index 000000000..09f6249f4 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_setup_model_handler.py @@ -0,0 +1,462 @@ +"""Closed provider-shape and metadata checks before setup model spend.""" + +import json +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from types import SimpleNamespace + +import pytest + +from app.adapters.project_agents.openrouter_policy import openrouter_request_options +from app.adapters.project_agents.setup_model_handler import ( + OpenRouterSetupProvider, + SetupModelProxyHandler, + SetupProviderResponse, + _bounded_response_bytes, + _completed_reply, + _validate_request, +) +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.interfaces.setup_model_proxy import SetupModelProxyRequest, SetupModelProxySubject +from app.modules.model_usage.api import ( + ModelCallUnavailable, + ModelCallUsage, + ProjectModelProfile, + SetupModelEffectAuthorityFacts, + SetupModelUsageSlotKey, +) +from tests.checkers_model_calls.test_model_call_contract import profile_spec + + +def _profile(*, provider="openrouter", api="chat_completions", model="openai/model"): + source = profile_spec().model_dump(mode="python") + source.update( + provider=provider, + api=api, + model_snapshot=model, + maximum_input_tokens=1_050_000, + maximum_output_tokens=128_000, + maximum_calls=1, + maximum_total_input_tokens=1_050_000, + maximum_total_output_tokens=128_000, + allowed_tools=("read_document", "search_document"), + maximum_tool_calls=10, + maximum_tool_output_bytes=100_000, + ) + source["pricing"].update( + uncached_input_nanodollars_per_token=5_000, + cached_input_nanodollars_per_token=400, + output_nanodollars_per_token=18_000, + ) + spec = type(profile_spec()).model_validate(source) + return ProjectModelProfile( + operation_id=new_record_id(), + actor_profile_id=new_record_id(), + spec=spec, + profile_digest=canonical_json_hash(spec.model_dump(mode="json")), + profile_id=new_record_id(), + authorization_evidence_id=new_record_id(), + created_at=datetime.now(UTC), + ) + + +def _metadata(): + return { + "data": { + "id": "openai/model", + "canonical_slug": "openai/model-20261023", + "context_length": 1_050_000, + "top_provider": { + "context_length": 1_050_000, + "max_completion_tokens": 128_000, + }, + "supported_parameters": [ + "max_tokens", + "reasoning_effort", + "tool_choice", + "tools", + "verbosity", + ], + "pricing": { + "prompt": "0.000002", + "completion": "0.000012", + "input_cache_read": "0.0000002", + "input_cache_write": "0.0000025", + "overrides": [ + { + "min_prompt_tokens": 272_000, + "prompt": "0.000004", + "completion": "0.000018", + "input_cache_read": "0.0000004", + "input_cache_write": "0.000005", + } + ], + }, + } + } + + +class _MetadataClient: + def __init__(self, payload): + self.payload = payload + self.gets = [] + + @asynccontextmanager + async def stream(self, method, url, **_kwargs): + assert method == "GET" + self.gets.append(url) + body = json.dumps(self.payload).encode() + + class Response: + status_code = 200 + + async def aiter_bytes(self): + yield body + + yield Response() + + async def aclose(self): + return None + + +async def test_openrouter_reserves_full_profile_only_after_current_metadata(monkeypatch): + import httpx + + client = _MetadataClient(_metadata()) + monkeypatch.setattr(httpx, "AsyncClient", lambda **_kwargs: client) + provider = OpenRouterSetupProvider(api_key="private", profile=_profile()) + + assert await provider.count_input({"messages": []}) == 1_050_000 + assert client.gets == ["https://openrouter.ai/api/v1/model/openai/model"] + + +async def test_provider_body_reader_stops_before_accumulating_over_profile(): + class Response: + async def aiter_bytes(self): + yield b"a" * 8 + yield b"b" + + with pytest.raises(ValueError, match="exceeds profile"): + await _bounded_response_bytes(Response(), 8) + + +@pytest.mark.parametrize("mutation", ["context", "pricing", "alias", "parameters"]) +async def test_openrouter_metadata_drift_fails_before_dispatch(monkeypatch, mutation): + import httpx + + payload = _metadata() + data = payload["data"] + if mutation == "context": + data["context_length"] = data["top_provider"]["context_length"] = 1_050_001 + elif mutation == "pricing": + data["pricing"]["overrides"][0]["completion"] = "0.000018001" + elif mutation == "alias": + data["id"] = "openai/resolved-other-model" + else: + data["supported_parameters"].remove("tools") + client = _MetadataClient(payload) + monkeypatch.setattr(httpx, "AsyncClient", lambda **_kwargs: client) + provider = OpenRouterSetupProvider(api_key="private", profile=_profile()) + + with pytest.raises(ModelCallUnavailable): + await provider.count_input({"messages": []}) + + +def _subject(profile): + return SetupModelProxySubject( + compilation_attempt_id=new_record_id(), + project_id=profile.spec.project_id, + guide_id=new_record_id(), + source_snapshot_id=new_record_id(), + setup_run_id=new_record_id(), + setup_generation=1, + runtime_configuration_hash=canonical_json_hash({"runtime": 1}), + profile_id=profile.profile_id, + profile_version=profile.spec.profile_version, + profile_digest=profile.profile_digest, + provider=profile.spec.provider, + api=profile.spec.api, + model=profile.spec.model_snapshot, + ) + + +def _tools(): + return [ + {"type": "function", "function": {"name": "read_document"}}, + {"type": "function", "function": {"name": "search_document"}}, + ] + + +@pytest.mark.parametrize("extra", [{"prompt": {"id": "remote"}}, {"service_tier": "flex"}]) +def test_openai_remote_prompt_and_unpriced_tier_are_rejected(extra): + profile = _profile(provider="openai", api="responses", model="gpt-exact") + body = { + "model": "gpt-exact", + "input": "bounded", + "instructions": "bounded", + "max_output_tokens": 128_000, + "parallel_tool_calls": False, + "store": False, + "stream": False, + "truncation": "disabled", + "reasoning": {"effort": "medium"}, + "text": { + "verbosity": "low", + "format": {"type": "json_schema", "name": "result", "strict": True, "schema": {}}, + }, + "tools": _tools(), + **extra, + } + request = SetupModelProxyRequest( + subject=_subject(profile), + phase="draft", + call_index=0, + provider_request_json=json.dumps(body, sort_keys=True, separators=(",", ":")), + ) + + with pytest.raises(ModelCallUnavailable): + _validate_request(profile, request, body) + + +def test_openrouter_requires_locked_settings_and_routing_controls(): + profile = _profile() + body = { + "model": "openai/model", + "messages": [], + "max_tokens": 128_000, + "stream": False, + "tools": _tools(), + "usage": {"include": True}, + "reasoning_effort": "high", + "verbosity": "low", + **openrouter_request_options(), + } + request = SetupModelProxyRequest( + subject=_subject(profile), + phase="draft", + call_index=0, + provider_request_json=json.dumps(body, sort_keys=True, separators=(",", ":")), + ) + + with pytest.raises(ModelCallUnavailable): + _validate_request(profile, request, body) + + +def test_paid_quote_heavy_response_becomes_bounded_unavailable_reply(): + profile = _profile() + body = { + "model": "openai/model", + "messages": [], + "max_tokens": 128_000, + "stream": False, + "tools": _tools(), + "usage": {"include": True}, + "reasoning_effort": "medium", + "verbosity": "low", + **openrouter_request_options(), + } + request = SetupModelProxyRequest( + subject=_subject(profile), + phase="draft", + call_index=0, + provider_request_json=json.dumps(body, sort_keys=True, separators=(",", ":")), + ) + response_json = json.dumps( + {"output": '"' * 500_000}, sort_keys=True, separators=(",", ":") + ) + assert len(response_json.encode()) < 1024 * 1024 + + reply = _completed_reply( + request, + profile, + status_code=200, + response_json=response_json, + ) + + assert reply.outcome == "unavailable" + assert reply.failure_code == "model_response_too_large" + + +async def test_handler_persists_observation_before_response_art_and_replays_without_send(): + profile = _profile() + body = { + "model": "openai/model", + "messages": [], + "max_tokens": 128_000, + "stream": False, + "tools": _tools(), + "usage": {"include": True}, + "reasoning_effort": "medium", + "verbosity": "low", + **openrouter_request_options(), + } + request = SetupModelProxyRequest( + subject=_subject(profile), + phase="draft", + call_index=0, + provider_request_json=json.dumps(body, sort_keys=True, separators=(",", ":")), + ) + key = SetupModelUsageSlotKey( + compilation_attempt_id=request.subject.compilation_attempt_id, + project_id=request.subject.project_id, + guide_id=request.subject.guide_id, + source_snapshot_id=request.subject.source_snapshot_id, + setup_run_id=request.subject.setup_run_id, + setup_generation=request.subject.setup_generation, + runtime_configuration_hash=request.subject.runtime_configuration_hash, + profile_id=request.subject.profile_id, + profile_version=request.subject.profile_version, + profile_digest=request.subject.profile_digest, + phase=request.phase, + call_index=request.call_index, + ) + slot_id, effect_id, internal_id = (new_record_id() for _ in range(3)) + effect = SetupModelEffectAuthorityFacts( + project_id=profile.spec.project_id, + slot_id=slot_id, + slot_key=key, + effect_id=effect_id, + internal_call_request_id=internal_id, + ) + prepared = SimpleNamespace( + slot_key=key, + slot_id=slot_id, + effect_id=effect_id, + internal_call_request_id=internal_id, + ) + dispatch = SimpleNamespace( + replayed=False, + effect=effect, + internal_call_request_id=internal_id, + ) + events = [] + stored = {} + + class Session: + @asynccontextmanager + async def begin(self): + yield self + + @asynccontextmanager + async def sessions(): + yield Session() + + class Profiles: + async def read_exact(self, *_args, **_kwargs): + return profile + + class Calls: + completed = None + + async def recover_completed(self, *_args): + events.append("recover") + return self.completed + + async def prepare(self, *_args): + events.append("prepare") + return prepared + + async def reserve_dispatch(self, *_args): + events.append("reserve") + return dispatch + + async def mark_uncertain(self, *_args, provider_response_id=None): + events.append(("observe", provider_response_id)) + + async def complete(self, *_args, response_evidence, **_kwargs): + events.append("complete") + self.completed = SimpleNamespace( + effect=effect, + response_evidence=response_evidence, + ) + return self.completed + + calls = Calls() + + class Outputs: + async def store_setup_output(self, artifact): + data = b"".join([chunk async for chunk in artifact.byte_source]) + events.append("store_" + artifact.selector.role) + stored[artifact.selector.role] = data + return SimpleNamespace( + status="verified", + put_attempt_id=new_record_id(), + verification_receipt_id=new_record_id(), + ) + + async def read_setup_response(self, *_args): + events.append("read_response") + return stored["model_response"] + + class Bindings: + async def bind_setup_output(self, binding): + events.append("bind_" + binding.selector.role) + return SimpleNamespace( + binding_id=new_record_id(), + content_id=new_record_id(), + put_attempt_id=binding.put_attempt_id, + verification_receipt_id=binding.verification_receipt_id, + ) + + provider_calls = 0 + response_json = json.dumps( + { + "id": "response-1", + "model": "openai/model", + "choices": [{"message": {"content": "bounded"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1}, + }, + sort_keys=True, + separators=(",", ":"), + ) + + class Provider: + async def count_input(self, _body): + return 1 + + async def dispatch(self, _body): + nonlocal provider_calls + provider_calls += 1 + events.append("provider") + return SetupProviderResponse( + status_code=200, + response_json=response_json, + provider_response_id="response-1", + provider_model="openai/model", + usage=ModelCallUsage( + input_tokens=1, + cached_input_tokens=0, + output_tokens=1, + reasoning_tokens=0, + ), + ) + + @asynccontextmanager + async def provider(_profile): + yield Provider() + + handler = SetupModelProxyHandler( + sessions=sessions, + authorization=lambda *_args: _async_value(object()), + calls=lambda *_args: calls, + profiles=lambda _session: Profiles(), + outputs=Outputs(), + bindings=lambda _session: Bindings(), + provider=provider, + ) + + assert (await handler.handle(request)).outcome == "completed" + first_events = list(events) + assert first_events.index(("observe", "response-1")) < first_events.index( + "store_model_response" + ) + assert first_events[-1] == "complete" + + assert (await handler.handle(request)).outcome == "completed" + assert provider_calls == 1 + assert events[-2:] == ["recover", "read_response"] + + +async def _async_value(value): + return value diff --git a/backend/tests/checkers_model_calls/test_setup_model_proxy_transport.py b/backend/tests/checkers_model_calls/test_setup_model_proxy_transport.py new file mode 100644 index 000000000..5222df35b --- /dev/null +++ b/backend/tests/checkers_model_calls/test_setup_model_proxy_transport.py @@ -0,0 +1,154 @@ +"""Credential-free setup model frames share the private listener without widening it.""" + +import json + +import pytest + +from app.adapters.checkers.model_proxy_transport import ModelProxyTransport +from app.adapters.project_agents.setup_model_proxy import ( + SetupModelProxySession, + SetupModelProxyUnavailable, +) +from app.core.identifiers import new_record_id +from app.interfaces.checker_model_proxy import ModelProxyReply +from app.interfaces.setup_model_proxy import SetupModelProxyReply, SetupModelProxySubject + + +def _sha(label: str) -> str: + import hashlib + + return "sha256:" + hashlib.sha256(label.encode()).hexdigest() + + +def _subject() -> SetupModelProxySubject: + return SetupModelProxySubject( + compilation_attempt_id=new_record_id(), + project_id=new_record_id(), + guide_id=new_record_id(), + source_snapshot_id=new_record_id(), + setup_run_id=new_record_id(), + setup_generation=3, + runtime_configuration_hash=_sha("runtime"), + profile_id=new_record_id(), + profile_version=4, + profile_digest=_sha("profile"), + provider="openai", + api="responses", + model="gpt-5.6-test", + ) + + +async def _unused_checker(_request): + return ModelProxyReply("unavailable", None, None, "unused", 0, True) + + +@pytest.mark.asyncio +async def test_setup_round_trip_preserves_exact_subject_and_canonical_body(tmp_path): + parent = tmp_path / "private" + parent.mkdir(mode=0o700) + socket = parent / "model.sock" + observed = [] + + async def handle(request): + observed.append(request) + return SetupModelProxyReply( + outcome="completed", + call_index=request.call_index, + status_code=200, + response_json=json.dumps( + {"id": "resp_test", "usage": {"input_tokens": 2, "output_tokens": 1}}, + sort_keys=True, + separators=(",", ":"), + ), + response_headers=(), + failure_code=None, + session_exhausted=False, + ) + + async with ModelProxyTransport( + socket_path=socket, + handler=_unused_checker, + setup_handler=handle, + ): + session = SetupModelProxySession( + socket_path=socket, + subject=_subject(), + maximum_calls=2, + maximum_output_tokens=128, + reasoning_effort="medium", + text_verbosity="low", + timeout_seconds=2.0, + ) + reply = await session.dispatch( + "draft", + {"model": "gpt-5.6-test", "input": "bounded", "max_output_tokens": 128}, + ) + + assert reply.outcome == "completed" + assert len(observed) == 1 + assert observed[0].subject == session._subject + assert observed[0].provider_request_json == ( + '{"input":"bounded","max_output_tokens":128,"model":"gpt-5.6-test"}' + ) + + +@pytest.mark.asyncio +async def test_unavailable_dispatch_permanently_blocks_sdk_retry(tmp_path): + parent = tmp_path / "private" + parent.mkdir(mode=0o700) + socket = parent / "model.sock" + calls = 0 + + async def handle(request): + nonlocal calls + calls += 1 + return SetupModelProxyReply( + outcome="unavailable", + call_index=request.call_index, + status_code=None, + response_json=None, + response_headers=(), + failure_code="model_provider_delivery_uncertain", + session_exhausted=True, + ) + + async with ModelProxyTransport( + socket_path=socket, + handler=_unused_checker, + setup_handler=handle, + ): + session = SetupModelProxySession( + socket_path=socket, + subject=_subject(), + maximum_calls=3, + maximum_output_tokens=128, + reasoning_effort="medium", + text_verbosity="low", + timeout_seconds=2.0, + ) + with pytest.raises(SetupModelProxyUnavailable): + await session.dispatch("draft", {"model": "gpt-5.6-test"}) + with pytest.raises(SetupModelProxyUnavailable): + await session.dispatch("draft", {"model": "gpt-5.6-test"}) + + assert calls == 1 + + +def test_completed_reply_must_fit_after_inner_json_escaping(): + response_json = json.dumps( + {"output": '"' * 500_000}, + sort_keys=True, + separators=(",", ":"), + ) + assert len(response_json.encode()) < 1024 * 1024 + + with pytest.raises(ValueError, match="transport bound"): + SetupModelProxyReply( + outcome="completed", + call_index=0, + status_code=200, + response_json=response_json, + response_headers=(), + failure_code=None, + session_exhausted=True, + ) diff --git a/backend/tests/projects/guide_compilation/proposals/pg_support.py b/backend/tests/projects/guide_compilation/proposals/pg_support.py index 24f24c9c4..33c760bba 100644 --- a/backend/tests/projects/guide_compilation/proposals/pg_support.py +++ b/backend/tests/projects/guide_compilation/proposals/pg_support.py @@ -313,14 +313,21 @@ async def finalize_corrected_attempt(factory, values, actor, correction, *, outc runtime = ScriptedGuideRuntime() if outcome is not None: runtime.outcome = outcome + + @asynccontextmanager + async def no_model_session(_state, _context): + yield None + coordinator = project_guide_compilation_delivery_port( factory, material_factory=guide_document_manifest_port, document_tools_factory=document_tools(factory), + model_session_factory=no_model_session, checker_capabilities=capabilities, request_authority=guide_compilation_request_authority, execution_authority=guide_compilation_execution_authority, configuration_factory=runtime_configuration, + model_profiles=lambda _session: None, runtime_factory=lambda configuration: runtime, sufficiency_authorization_factory=guide_sufficiency_projection_authorization, policy_authorization_factory=artifact_policy_projection_authorization, diff --git a/backend/tests/projects/guide_compilation/test_live_worker.py b/backend/tests/projects/guide_compilation/test_live_worker.py index dcec3c48c..afacf9f8a 100644 --- a/backend/tests/projects/guide_compilation/test_live_worker.py +++ b/backend/tests/projects/guide_compilation/test_live_worker.py @@ -8,6 +8,7 @@ from app.core.config import get_settings from app.interfaces.external_services import UnknownExternalServiceProviderError from app.workers.task_settings import sync_task_settings + from .helpers import manager_runtime_configuration, runtime_configuration @@ -117,16 +118,19 @@ def test_runtime_factory_constructs_manager_authored_runtime_without_provider_cr worker, monkeypatch ): from app.adapters.project_agents.manager_authored import ManagerAuthoredProjectGuideRuntime - from app.interfaces.external_services import ExternalServiceConfigurationError + from app.adapters.project_agents.openai_agent_sdk import OpenAIAgentSdkProjectGuideRuntime monkeypatch.delenv("OPENAI_API_KEY", raising=False) configuration = manager_runtime_configuration() runtime = worker.create_project_guide_runtime(configuration) assert isinstance(runtime, ManagerAuthoredProjectGuideRuntime) assert runtime.identity == configuration.adapter_identity - # The OpenAI runtime still refuses to start without its credential. - with pytest.raises(ExternalServiceConfigurationError): - worker.create_project_guide_runtime(runtime_configuration()) + # Provider credentials belong only to the trusted model proxy; construction + # is credential-free and execution still requires the typed model capability. + assert isinstance( + worker.create_project_guide_runtime(runtime_configuration()), + OpenAIAgentSdkProjectGuideRuntime, + ) def test_stale_coordinator_error_is_rejected_without_transport_retry(worker, monkeypatch): diff --git a/backend/tests/test_agent_runtime.py b/backend/tests/test_agent_runtime.py index 7fb2ffc9f..aaf300fd3 100644 --- a/backend/tests/test_agent_runtime.py +++ b/backend/tests/test_agent_runtime.py @@ -9,32 +9,39 @@ from types import SimpleNamespace from uuid import uuid4 -import pytest import httpx2 +import pytest from pydantic import ValidationError -from app.adapters.project_agents.openai_agent_sdk import OpenAIAgentSdkProjectGuideRuntime from app.adapters.project_agents import build_project_guide_adapter_profile +from app.adapters.project_agents.openai_agent_sdk import OpenAIAgentSdkProjectGuideRuntime from app.core.config import Settings from app.core.project_agents import ( project_guide_runtime_configuration, ) from app.core.project_guide_instructions import PROJECT_GUIDE_INSTRUCTIONS +from app.interfaces.guide_document_tools import GuideDocumentToolResult from app.interfaces.project_agents import ( - ProjectAgentRuntimeError, ProjectAgentRuntimeConfigurationError, + ProjectAgentRuntimeError, ProjectGuideCompilationInvalidOutputError, canonical_project_guide_compilation_context_bytes, invalid_compilation_failure_code, project_guide_compilation_prompt_bytes, ) from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration -from app.interfaces.guide_document_tools import GuideDocumentToolResult +from app.interfaces.setup_model_proxy import SetupModelProxyReply from app.modules.projects.api.guide_documents import GuideDocumentManifest from tests.projects.guide_compilation.helpers import ( - SOURCE_SHA256, context, ids, generated_result as result, runtime_configuration, + SOURCE_SHA256, + context, + ids, + runtime_configuration, valid_specification, ) +from tests.projects.guide_compilation.helpers import ( + generated_result as result, +) @pytest.fixture(autouse=True) @@ -85,7 +92,38 @@ def _sdk_result(agent, value=None): )) -async def _compile(runtime, compilation_context, *, document_text="Assigned guide text."): +class _TestModelSession: + maximum_output_tokens = 4096 + reasoning_effort = "medium" + text_verbosity = "low" + + def __init__(self, dispatch=None): + self._dispatch = dispatch + + async def dispatch(self, phase, body): + if self._dispatch is None: + raise AssertionError("test unexpectedly dispatched a model request") + response = await self._dispatch(phase, body) + return SetupModelProxyReply( + outcome="completed", + call_index=0, + status_code=response.status_code, + response_json=json.dumps( + response.json(), sort_keys=True, separators=(",", ":") + ), + response_headers=(), + failure_code=None, + session_exhausted=False, + ) + + +async def _compile( + runtime, + compilation_context, + *, + document_text="Assigned guide text.", + model=None, +): """Supply scripted prior document access and always release circuit admission.""" opened = { compilation_context.material.handle_for(document) @@ -135,11 +173,14 @@ async def search_document(handle, query, *, offset=0, limit=20): "operation": "search", "text": value.text if query in value.text else "", }) - capabilities = SimpleNamespace(tools=SimpleNamespace( - opened_handles=opened_handles, - read_document=read_document, - search_document=search_document, - )) + capabilities = SimpleNamespace( + tools=SimpleNamespace( + opened_handles=opened_handles, + read_document=read_document, + search_document=search_document, + ), + model=model or _TestModelSession(), + ) runtime.admit_execution() try: return await runtime.compile_project_guide(compilation_context, capabilities) @@ -152,10 +193,10 @@ def test_unified_compilation_instructions_preserve_untrusted_and_lifecycle_bound assert text in PROJECT_GUIDE_INSTRUCTIONS -def test_runtime_requires_credentials_before_dispatch(monkeypatch): +def test_runtime_never_requires_provider_credentials(monkeypatch): monkeypatch.delenv("OPENAI_API_KEY") - with pytest.raises(ProjectAgentRuntimeConfigurationError, match="credentials are unavailable"): - OpenAIAgentSdkProjectGuideRuntime(runtime_configuration()) + runtime = OpenAIAgentSdkProjectGuideRuntime(runtime_configuration()) + assert runtime.identity.provider_key == "openai_agents_sdk" def test_configuration_concerns_are_independent_and_secret_free(): @@ -273,8 +314,8 @@ def test_snapshot_changes_identity_but_is_absent_from_provider_user_prompt(): async def test_unified_compilation_uses_scoped_tools_and_strict_output(monkeypatch): - from agents import Runner, FunctionTool import openai + from agents import FunctionTool, Runner clients = [] client_type = openai.AsyncOpenAI @@ -307,6 +348,8 @@ async def run(agent, prompt, **kwargs): assert agent.model_settings.store is False assert agent.model_settings.parallel_tool_calls is False assert agent.model_settings.extra_body is None + assert agent.model_settings.reasoning.effort == "medium" + assert agent.model_settings.verbosity == "low" assert agent.model_settings.retry.max_retries == 2 assert agent.model_settings.context_management is None assert agent.model_settings.response_include is None @@ -327,7 +370,7 @@ async def run(agent, prompt, **kwargs): assert calls[0][0] is calls[1][0] assert clients[0].max_retries == 0 and clients[0].is_closed() assert clients[0].timeout == 300 - assert str(clients[0].base_url).rstrip("/") == "https://api.openai.com/v1" + assert str(clients[0].base_url).rstrip("/") == "https://model-proxy.invalid/v1" async def test_actual_sdk_tool_loop_uses_only_openrouter_chat_and_neutral_tools( @@ -348,9 +391,9 @@ async def test_actual_sdk_tool_loop_uses_only_openrouter_chat_and_neutral_tools( ] ) - async def dispatch(request): - body = json.loads(request.content) - captured.append((str(request.url), dict(request.headers), body)) + async def dispatch(phase, body): + captured.append((phase, body)) + request = httpx2.Request("POST", "https://model-proxy.invalid/v1/chat/completions") return httpx2.Response(200, json=next(responses), request=request) clients, http_options = _patch_openai_transport(monkeypatch, dispatch) @@ -362,15 +405,20 @@ async def dispatch(request): } ) compilation = compilation.model_copy(update={"runtime_configuration": config}) - output = await _compile(OpenAIAgentSdkProjectGuideRuntime(config), compilation) + output = await _compile( + OpenAIAgentSdkProjectGuideRuntime(config), + compilation, + model=_TestModelSession(dispatch), + ) assert output == result() assert len(captured) == 4 - assert all(url == "https://openrouter.ai/api/v1/chat/completions" for url, _, _ in captured) - assert all(headers["authorization"] == "Bearer unit-test-only" for _, headers, _ in captured) - for _url, _headers, body in captured: + assert [phase for phase, _ in captured] == ["draft", "draft", "review", "review"] + for _phase, body in captured: assert body["model"] == "openai/gpt-5.6-terra" assert body["store"] is False assert body["parallel_tool_calls"] is False + assert body["reasoning_effort"] == "medium" + assert body["verbosity"] == "low" assert body["provider"] == { "allow_fallbacks": False, "data_collection": "deny", @@ -398,12 +446,12 @@ async def dispatch(request): owned_http_client = observed.pop("http_client") assert owned_http_client is not None assert observed == { - "api_key": "unit-test-only", - "base_url": "https://openrouter.ai/api/v1", + "api_key": "setup-model-proxy", + "base_url": "https://model-proxy.invalid/v1", "max_retries": 0, "timeout": 300, } - assert http_options == [{"trust_env": False, "follow_redirects": False}] + assert http_options == [] assert client.is_closed() @@ -423,8 +471,9 @@ async def test_actual_sdk_http_failures_are_bounded_and_sanitized(monkeypatch, f else: responses = None - async def dispatch(request): - calls.append(str(request.url)) + async def dispatch(_phase, body): + calls.append(body["model"]) + request = httpx2.Request("POST", "https://model-proxy.invalid/v1/chat/completions") if failure == "provider_fault": return httpx2.Response( 401, @@ -449,7 +498,11 @@ async def dispatch(request): else ProjectAgentRuntimeError ) with pytest.raises(expected) as caught: - await _compile(OpenAIAgentSdkProjectGuideRuntime(config), compilation) + await _compile( + OpenAIAgentSdkProjectGuideRuntime(config), + compilation, + model=_TestModelSession(dispatch), + ) assert "private" not in str(caught.value) assert len(calls) == {"malformed_schema": 3, "tool_bound": 2, "provider_fault": 1}[failure] assert clients[0][1].is_closed() @@ -459,7 +512,7 @@ async def test_actual_sdk_http_request_is_cancelled_and_client_closed(monkeypatc """Caller cancellation reaches the SDK transport and awaits client shutdown.""" started = asyncio.Event() - async def dispatch(_request): + async def dispatch(_phase, _body): started.set() await asyncio.Event().wait() @@ -472,7 +525,11 @@ async def dispatch(_request): }) compilation = compilation.model_copy(update={"runtime_configuration": config}) task = asyncio.create_task( - _compile(OpenAIAgentSdkProjectGuideRuntime(config), compilation) + _compile( + OpenAIAgentSdkProjectGuideRuntime(config), + compilation, + model=_TestModelSession(dispatch), + ) ) await started.wait() task.cancel() @@ -774,9 +831,7 @@ async def run(*args): @pytest.mark.parametrize("redacted", [True, False]) async def test_actual_sdk_parser_preserves_known_invalid_output(monkeypatch, kind, expected, redacted): """Exercise the installed SDK parser before the adapter receives any result.""" - from agents import Runner - - from agents import _debug + from agents import Runner, _debug monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) payload = _provider_payload() if kind == "schema": @@ -977,7 +1032,9 @@ async def run(*args): def test_sdk_output_schema_exposes_the_identifier_rule_to_the_model(): """The provider sees the same bounded lowercase rule that parsing enforces.""" import re + from agents import AgentOutputSchema + from app.interfaces.project_agents import ProjectGuideCompilationResult schema = AgentOutputSchema(ProjectGuideCompilationResult).json_schema() @@ -1001,6 +1058,7 @@ def test_sdk_output_schema_exposes_the_identifier_rule_to_the_model(): def test_sdk_output_schema_has_one_intake_configuration_and_separate_post_parameters(): """Actual SDK schema cannot ask the model for two conflicting intake policies.""" from agents import AgentOutputSchema + from app.interfaces.project_agents import ProjectGuideCompilationResult schema = AgentOutputSchema(ProjectGuideCompilationResult).json_schema() @@ -1135,7 +1193,8 @@ def test_manager_authored_settings_snapshot_records_absent_inference(): def _manager_material(rules: bytes, *, extra: tuple[bytes, ...] = (), source_ids=()): """Return a context with one PDF original, the rules document and optional Markdown.""" from tests.projects.guide_compilation.helpers import ( - SOURCE_BYTES, manager_runtime_configuration, + SOURCE_BYTES, + manager_runtime_configuration, ) base = context(ids(), configuration=manager_runtime_configuration()) @@ -1235,6 +1294,7 @@ async def _manager_compile(compilation_context, grant): async def test_manager_authored_runtime_converts_rules_without_credentials_or_network(monkeypatch): import socket + from app.adapters.project_agents.manager_authored import ManagerAuthoredProjectGuideRuntime monkeypatch.delenv("OPENAI_API_KEY") diff --git a/docker-compose.agent-loop.yml b/docker-compose.agent-loop.yml index fbb25737b..d68a1376e 100644 --- a/docker-compose.agent-loop.yml +++ b/docker-compose.agent-loop.yml @@ -30,7 +30,17 @@ x-agent-loop-backend: &agent-loop-backend services: backend: <<: *agent-loop-backend - worker: *agent-loop-backend + worker: + <<: *agent-loop-backend + environment: + WORKSTREAM_ARTIFACT_SCRATCH_ROOT: /var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-material/_data + WORKSTREAM_EXTERNAL_CHECKER_MATERIAL_ROOT: /var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-material/_data + WORKSTREAM_EXTERNAL_CHECKER_SERVICE_SOCKET: /run/ws-checker/checker.sock + WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET: /run/ws-model-proxy/model-proxy.sock + volumes: + - checker_material:/var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-material/_data + - checker_socket:/run/ws-checker:ro + - checker_model_proxy:/run/ws-model-proxy:ro beat: *agent-loop-backend model-proxy: diff --git a/docker-compose.yml b/docker-compose.yml index f0d7da35d..b7e7a8a31 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,8 +85,6 @@ services: group_add: ["10001"] environment: <<: *backend-environment - OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} - OPENAI_API_KEY: ${OPENAI_API_KEY:-} WORKSTREAM_GUIDE_DOCUMENT_PARSER_SOCKET: /var/lib/workstream/guide-runtime/guide.sock volumes: - ./backend:/workspace/backend @@ -125,6 +123,10 @@ services: model-proxy: <<: *backend-runtime profiles: ["model-proxy"] + environment: + <<: *backend-environment + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} command: [python, -m, app.workers.checker_model_proxy] stop_grace_period: 125m depends_on: diff --git a/scripts/test_local_guide_runtime.py b/scripts/test_local_guide_runtime.py index ccd1996d8..3df91a846 100644 --- a/scripts/test_local_guide_runtime.py +++ b/scripts/test_local_guide_runtime.py @@ -1,14 +1,14 @@ """Local guide parsing must retain the same isolation boundary as deployment.""" -from contextlib import chdir import json import os -from pathlib import Path import subprocess import sys import tempfile -from types import ModuleType import unittest +from contextlib import chdir +from pathlib import Path +from types import ModuleType from unittest.mock import patch import yaml @@ -81,15 +81,23 @@ def test_parser_has_no_secrets_network_or_source_mount(self): self.assertIn("size=64m", parser["tmpfs"][0]) self.assertIn("--healthcheck", parser["healthcheck"]["test"]) - def test_only_worker_receives_provider_keys_and_readonly_parser_socket(self): - for name in ("backend", "beat", "guide-document-parser"): + def test_only_model_proxy_receives_provider_keys(self): + for name in ("backend", "worker", "beat", "guide-document-parser"): service = self.services[name] self.assertFalse( {"OPENAI_API_KEY", "OPENROUTER_API_KEY"} & set(service.get("environment", {})), name, ) - if name != "guide-document-parser": + if name in {"backend", "beat"}: self.assertFalse(any("guide_parser_socket:" in mount for mount in service["volumes"])) + proxy = self.services["model-proxy"] + self.assertEqual( + {"OPENAI_API_KEY", "OPENROUTER_API_KEY"} + & set(proxy.get("environment", {})), + {"OPENAI_API_KEY", "OPENROUTER_API_KEY"}, + ) + + def test_worker_receives_readonly_parser_socket(self): worker = self.services["worker"] self.assertIn("10001", worker["group_add"]) self.assertIn("guide_parser_socket:/var/lib/workstream/guide-runtime:ro", worker["volumes"]) From fbe2b377fdd10b1d58d28b34c76e5cbd5c7b5687 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 20:35:52 +0100 Subject: [PATCH 019/115] Test setup model artifact custody end to end --- .../test_setup_model_handler_postgresql.py | 459 ++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py new file mode 100644 index 000000000..2813ca7eb --- /dev/null +++ b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py @@ -0,0 +1,459 @@ +"""Real PostgreSQL and MinIO custody for one setup-model provider call.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from dataclasses import asdict, replace +import os +from uuid import UUID, uuid4 + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from tests.authentication.fixtures import ( + auth_database_env as auth_database_env, + clear_settings_cache as clear_settings_cache, + rsa_signing_material as rsa_signing_material, +) +from tests.authorization.admin_access.fixtures import ( + admin_access as admin_access, + signed_access as signed_access, +) + +from app.adapters.artifacts import ( + create_artifact_store_bootstrap, + setup_model_evidence, + setup_model_output_binding, + setup_model_output_storage, +) +from app.adapters.auth import setup_model_service_authorization +from app.adapters.checkers import model_profile_reader +from app.adapters.model_usage import setup_model_usage_service +from app.adapters.project_agents.openrouter_policy import openrouter_request_options +from app.adapters.project_agents.setup_model_handler import ( + SetupModelProxyHandler, + SetupProviderResponse, +) +from app.adapters.projects import setup_model_usage_owner +from app.interfaces.artifacts import ArtifactStoreNamespaceClaim +from app.interfaces.setup_model_proxy import ( + SetupModelProxyRequest, + SetupModelProxySubject, + canonical_provider_json, +) +from app.modules.actors.api import ServiceIdentity +from app.modules.artifacts.preparation import ( + ArtifactPreparationService, + ArtifactScratchManager, +) +from app.modules.artifacts.service import artifact_storage_namespace_spec +from app.modules.authorization.api import ( + ActorIdentityFacts, + ActorKind, + ProjectGuideCompilationExecutePreflightFacts, + ProjectGuideCompilationRequestOrigin, +) +from app.modules.model_usage.api import ( + ModelCallUsage, + ProjectModelProfile, + ProjectModelProfileSpec, +) +from tests.artifact_store_helpers import ( + artifact_admission_limit_settings, + artifact_preparation_limits, +) +from tests.checkers.execution.support import provision_post_submit_service +from tests.checkers_model_calls.test_model_call_contract import profile_spec +from tests.projects.guide_compilation.helpers import ( + context, + identity, + persistence_facts, + runtime_configuration, + seed_database, + service_actor, +) +from tests.projects.guide_compilation.test_authorized_execution_service import ( + _execution_service, +) +from tests.projects.guide_compilation.test_authorized_request_service import ( + _authorized_service, + _request, + _seed_human, +) + + +def _setup_profile(project_id: UUID) -> ProjectModelProfileSpec: + source = profile_spec().model_dump(mode="python") + source.update( + project_id=project_id, + profile_version=1, + provider="openrouter", + api="chat_completions", + model_snapshot="openai/model", + maximum_input_tokens=1_050_000, + maximum_output_tokens=128_000, + maximum_calls=1, + maximum_total_input_tokens=1_050_000, + maximum_total_output_tokens=128_000, + allowed_tools=("read_document", "search_document"), + maximum_tool_calls=10, + maximum_tool_output_bytes=100_000, + ) + source["pricing"].update( + uncached_input_nanodollars_per_token=5_000, + cached_input_nanodollars_per_token=400, + output_nanodollars_per_token=18_000, + ) + return ProjectModelProfileSpec.model_validate(source) + + +async def _publish_profile(access, project_id: UUID) -> ProjectModelProfile: + await access.signed.grant(access.admin, access.target, role="operator") + spec = _setup_profile(project_id) + response = await access.signed.client.post( + f"/api/v1/operations/projects/{project_id}/model-profiles", + headers=access.target.headers | {"Idempotency-Key": str(uuid4())}, + json=spec.model_dump(mode="json"), + ) + assert response.status_code == 201, response.text + budget = await access.signed.client.post( + f"/api/v1/operations/projects/{project_id}/model-budget-configurations", + headers=access.target.headers | {"Idempotency-Key": str(uuid4())}, + json={ + "project_id": str(project_id), + "configuration_version": 1, + "authorized_total_nanodollars": 10_000_000_000, + "externally_reconciled_prior_spend_nanodollars": 0, + "lifetime_limit_nanodollars": 10_000_000_000, + }, + ) + assert budget.status_code == 201, budget.text + return ProjectModelProfile.model_validate(response.json()) + + +def _runtime(profile: ProjectModelProfile): + return runtime_configuration().model_copy( + update={ + "model_provider": profile.spec.provider, + "model_api": profile.spec.api, + "model": profile.spec.model_snapshot, + "model_profile_id": profile.profile_id, + "model_profile_version": profile.spec.profile_version, + "model_profile_digest": profile.profile_digest, + } + ) + + +def _preflight(values, requested, configuration): + attempt_identity = identity(context(values, configuration=configuration)) + complete = replace( + persistence_facts(values, requested.attempt_id, attempt_identity), + operation_id=requested.operation_id, + ) + names = ProjectGuideCompilationExecutePreflightFacts.__dataclass_fields__ + return ProjectGuideCompilationExecutePreflightFacts( + **{name: asdict(complete)[name] for name in names} + ) + + +async def _fence_setup_attempt(database_url, values, configuration): + human, link, _grant = await _seed_human(database_url, values) + actor = ActorIdentityFacts(human, link, ActorKind.HUMAN) + engine = create_async_engine(database_url) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + async with factory() as session: + requested = await _authorized_service(session, actor).authorize_request( + origin=ProjectGuideCompilationRequestOrigin(trigger="project_manager"), + actor=actor, + facts=_request(values, configuration), + identity=identity(context(values, configuration=configuration)), + runtime_configuration=configuration, + ) + async with factory() as session: + fenced = await _execution_service(session, service_actor(values)).fence_dispatch( + actor=service_actor(values), + facts=_preflight(values, requested, configuration), + ) + assert fenced.dispatch_permitted is True + return requested + finally: + await engine.dispose() + + +def _provider_request(profile: ProjectModelProfile) -> str: + return canonical_provider_json( + { + "model": profile.spec.model_snapshot, + "messages": [], + "max_tokens": profile.spec.maximum_output_tokens, + "stream": False, + "tools": [ + {"type": "function", "function": {"name": "read_document"}}, + {"type": "function", "function": {"name": "search_document"}}, + ], + "usage": {"include": True}, + "reasoning_effort": profile.spec.settings.reasoning_effort, + "verbosity": profile.spec.settings.text_verbosity, + **openrouter_request_options(), + } + ) + + +async def test_setup_request_and_response_use_verified_minio_custody_and_replay( + tmp_path, + isolated_database_env, + admin_access, # noqa: F811 - imported fixture is intentionally requested here + monkeypatch, +) -> None: + """Persist both private payloads in ART and replay without another provider send.""" + from tests import test_s3_artifact_store + from tests.test_s3_artifact_store import minio_settings, provision_minio_bucket + + monkeypatch.setattr( + test_s3_artifact_store, + "MINIO_BUCKET", + os.environ["WORKSTREAM_TEST_MINIO_BUCKET"], + ) + await provision_minio_bucket.__wrapped__() + settings = minio_settings(private_prefix=f"setup-model/{uuid4()}").model_copy( + update={ + **artifact_admission_limit_settings(64 * 1024), + "artifact_scratch_root": tmp_path / "setup-model-scratch", + "artifact_scratch_minimum_free_bytes": 0, + } + ) + bootstrap = create_artifact_store_bootstrap(settings, activation_guard=None) + namespace = artifact_storage_namespace_spec(settings, bootstrap) + store = bootstrap.initialize_after_namespace_claim( + ArtifactStoreNamespaceClaim( + bootstrap.identity, + bootstrap.namespace_identity, + namespace.namespace_fingerprint, + ) + ) + manager = ArtifactScratchManager( + root=tmp_path / "setup-model-preparation", + limits=artifact_preparation_limits(), + ) + engine = create_async_engine(isolated_database_env) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + values = await seed_database( + isolated_database_env, + namespace=namespace, + provider_object_ref="fixture/guide-source-not-read", + ) + for service_identity in ( + ServiceIdentity.ARTIFACT_CHECKER_OUTPUT, + ServiceIdentity.ARTIFACT_PUT_RESOLVER, + ServiceIdentity.ARTIFACT_VERIFIER, + ): + await provision_post_submit_service(factory, service_identity) + profile = await _publish_profile(admin_access, values["project"]) + configuration = _runtime(profile) + requested = await _fence_setup_attempt( + isolated_database_env, values, configuration + ) + attempt = context(values, configuration=configuration) + subject = SetupModelProxySubject( + compilation_attempt_id=requested.attempt_id, + project_id=values["project"], + guide_id=values["guide"], + source_snapshot_id=values["snapshot"], + setup_run_id=values["setup_1"], + setup_generation=1, + runtime_configuration_hash=configuration.sha256, + profile_id=profile.profile_id, + profile_version=profile.spec.profile_version, + profile_digest=profile.profile_digest, + provider=profile.spec.provider, + api=profile.spec.api, + model=profile.spec.model_snapshot, + ) + assert attempt.setup_run_id == subject.setup_run_id + request = SetupModelProxyRequest( + subject=subject, + phase="draft", + call_index=0, + provider_request_json=_provider_request(profile), + ) + response_marker = f"private-setup-response-{uuid4()}" + response_json = canonical_provider_json( + { + "id": "setup-response-1", + "model": profile.spec.model_snapshot, + "choices": [{"message": {"content": response_marker}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1}, + } + ) + provider_calls = 0 + reserve_observations: list[tuple[str, str, str]] = [] + + class Provider: + async def count_input(self, _body): + return 1 + + async def dispatch(self, _body): + nonlocal provider_calls + provider_calls += 1 + assert reserve_observations == [ + ("setup_model_call", "model_request", "verified") + ] + return SetupProviderResponse( + status_code=200, + response_json=response_json, + provider_response_id="setup-response-1", + provider_model=profile.spec.model_snapshot, + usage=ModelCallUsage( + input_tokens=1, + cached_input_tokens=0, + output_tokens=1, + reasoning_tokens=0, + ), + ) + + @asynccontextmanager + async def provider(_profile): + yield Provider() + + def calls(session, authority): + owner = setup_model_usage_service( + session, + authority=authority, + evidence=setup_model_evidence(session), + owner=setup_model_usage_owner(session), + ) + + class ObservedCalls: + def __getattr__(self, name): + return getattr(owner, name) + + async def reserve_dispatch(self, prepared, request_evidence): + row = ( + await session.execute( + text( + "select binding.resource_type,binding.logical_role,receipt.outcome " + "from artifact_bindings binding " + "join artifact_verification_receipts receipt " + "on receipt.id=binding.verification_receipt_id " + "where binding.id=:binding and binding.resource_id=:effect" + ), + { + "binding": str(request_evidence.binding_id), + "effect": str(prepared.effect_id), + }, + ) + ).one() + reserve_observations.append(tuple(row)) + return await owner.reserve_dispatch(prepared, request_evidence) + + return ObservedCalls() + + preparation = ArtifactPreparationService(manager) + outputs = setup_model_output_storage( + sessions=factory, + store=store, + namespace=namespace, + preparation=preparation, + settings=settings, + owner=setup_model_usage_owner, + ) + + def handler() -> SetupModelProxyHandler: + return SetupModelProxyHandler( + sessions=factory, + authorization=setup_model_service_authorization, + calls=calls, + profiles=model_profile_reader, + outputs=outputs, + bindings=lambda session: setup_model_output_binding( + session, + namespace=namespace, + owner=setup_model_usage_owner(session), + ), + provider=provider, + ) + + first = await handler().handle(request) + assert first.outcome == "completed" + assert first.response_json == response_json + assert provider_calls == 1 + + async with factory() as session: + state = ( + await session.execute( + text( + "select effect.id,effect.state,effect.request_binding_id," + "effect.response_binding_id,effect.settled_nanodollars," + "account.lifetime_reserved_nanodollars," + "account.lifetime_settled_nanodollars " + "from model_usage_effects effect " + "join setup_model_usage_slots slot on slot.id=effect.setup_slot_id " + "join model_usage_budget_accounts account on account.project_id=slot.project_id " + "where slot.compilation_attempt_id=:attempt" + ), + {"attempt": str(requested.attempt_id)}, + ) + ).one() + bindings = ( + await session.execute( + text( + "select binding.logical_role,binding.resource_id,receipt.outcome " + "from artifact_bindings binding " + "join artifact_verification_receipts receipt " + "on receipt.id=binding.verification_receipt_id " + "where binding.resource_type='setup_model_call' " + "and binding.resource_id=:effect order by binding.logical_role" + ), + {"effect": str(state.id)}, + ) + ).all() + assert [tuple(row) for row in bindings] == [ + ("model_request", str(state.id), "verified"), + ("model_response", str(state.id), "verified"), + ] + assert state.state == "completed" + assert state.request_binding_id is not None + assert state.response_binding_id is not None + assert state.lifetime_reserved_nanodollars == 0 + assert state.lifetime_settled_nanodollars == state.settled_nanodollars + settled = state.lifetime_settled_nanodollars + for table in ( + "model_usage_effects", + "artifact_contents", + "artifact_put_attempts", + "artifact_operation_receipts", + "artifact_verification_receipts", + "artifact_bindings", + "audit_events", + ): + leaked = await session.scalar( + text( + f"select exists(select 1 from {table} row " + "where to_jsonb(row)::text like :needle)" + ), + {"needle": f"%{response_marker}%"}, + ) + assert leaked is False, table + + replay = await handler().handle(request) + assert replay == first + assert provider_calls == 1 + async with factory() as session: + replay_state = ( + await session.execute( + text( + "select count(*) as effects,min(state) as state," + "min(settled_nanodollars) as settled from model_usage_effects effect " + "join setup_model_usage_slots slot on slot.id=effect.setup_slot_id " + "where slot.compilation_attempt_id=:attempt" + ), + {"attempt": str(requested.attempt_id)}, + ) + ).one() + assert replay_state == (1, "completed", settled) + finally: + manager.close() + bootstrap.close() + await engine.dispose() From 3e922bc58be93f801511c7638f6b43bfe4a6b65f Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 20:36:14 +0100 Subject: [PATCH 020/115] fix(model-usage): count full setup request context --- .../project_agents/setup_model_handler.py | 12 +++- .../test_setup_model_handler.py | 60 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/backend/app/adapters/project_agents/setup_model_handler.py b/backend/app/adapters/project_agents/setup_model_handler.py index 594cc2135..c131af5bf 100644 --- a/backend/app/adapters/project_agents/setup_model_handler.py +++ b/backend/app/adapters/project_agents/setup_model_handler.py @@ -58,7 +58,17 @@ } ) _COUNT_FIELDS = frozenset( - {"input", "instructions", "model", "parallel_tool_calls", "tools", "truncation"} + { + "input", + "instructions", + "model", + "parallel_tool_calls", + "reasoning", + "text", + "tool_choice", + "tools", + "truncation", + } ) _OPENROUTER_CHAT_FIELDS = frozenset( { diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler.py b/backend/tests/checkers_model_calls/test_setup_model_handler.py index 09f6249f4..018aaeaba 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_handler.py +++ b/backend/tests/checkers_model_calls/test_setup_model_handler.py @@ -9,6 +9,7 @@ from app.adapters.project_agents.openrouter_policy import openrouter_request_options from app.adapters.project_agents.setup_model_handler import ( + OpenAISetupProvider, OpenRouterSetupProvider, SetupModelProxyHandler, SetupProviderResponse, @@ -141,6 +142,65 @@ async def aiter_bytes(self): await _bounded_response_bytes(Response(), 8) +async def test_openai_count_includes_every_semantic_input_field(): + recorded = [] + + class Counter: + async def count(self, **kwargs): + recorded.append(kwargs) + return SimpleNamespace(input_tokens=73) + + provider = object.__new__(OpenAISetupProvider) + provider._counter = SimpleNamespace( + responses=SimpleNamespace(input_tokens=Counter()) + ) + schema = { + "type": "object", + "properties": {f"field_{index}": {"type": "string"} for index in range(200)}, + } + body = { + "model": "gpt-exact", + "input": "bounded", + "instructions": "bounded", + "parallel_tool_calls": False, + "reasoning": {"effort": "medium"}, + "text": { + "verbosity": "low", + "format": { + "type": "json_schema", + "name": "result", + "strict": True, + "schema": schema, + }, + }, + "tool_choice": "auto", + "tools": _tools(), + "truncation": "disabled", + "max_output_tokens": 128_000, + "store": False, + } + + assert await provider.count_input(body) == 73 + assert recorded == [ + { + key: value + for key, value in body.items() + if key + in { + "input", + "instructions", + "model", + "parallel_tool_calls", + "reasoning", + "text", + "tool_choice", + "tools", + "truncation", + } + } + ] + + @pytest.mark.parametrize("mutation", ["context", "pricing", "alias", "parameters"]) async def test_openrouter_metadata_drift_fails_before_dispatch(monkeypatch, mutation): import httpx From b67858cf991b8e803341bb8505ded8a506a898b5 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 20:49:48 +0100 Subject: [PATCH 021/115] fix(model-usage): recover retained setup responses --- .../project_agents/setup_model_handler.py | 180 +++++++++++++----- .../app/modules/artifacts/checker_outputs.py | 36 ++-- .../modules/checkers/api/output_custody.py | 13 +- backend/app/modules/model_usage/api.py | 6 + backend/app/modules/model_usage/setup.py | 21 +- .../test_model_shared_setup_postgresql.py | 4 + .../test_setup_model_handler.py | 45 ++++- backend/tests/test_artifact_architecture.py | 9 +- 8 files changed, 236 insertions(+), 78 deletions(-) diff --git a/backend/app/adapters/project_agents/setup_model_handler.py b/backend/app/adapters/project_agents/setup_model_handler.py index c131af5bf..46e4fddbc 100644 --- a/backend/app/adapters/project_agents/setup_model_handler.py +++ b/backend/app/adapters/project_agents/setup_model_handler.py @@ -24,6 +24,7 @@ ModelCallUnavailable, ModelCallUsage, ModelProfileSelector, + ObservedSetupModelUsageEffect, PreparedSetupModelUsageEffect, ProjectModelProfile, SetupModelOutputArtifactRequest, @@ -182,24 +183,7 @@ async def dispatch(self, body_json: str) -> SetupProviderResponse: ), provider_id if isinstance(provider_id, str) else None, ) - canonical = _canonical_bytes(value).decode("utf-8") - provider_model = value.get("model") - usage = _usage(value.get("usage")) - if ( - not isinstance(provider_id, str) - or not provider_id - or provider_model != self._profile.spec.model_snapshot - ): - raise ValueError("provider response identity is invalid") - return SetupProviderResponse( - status_code=response.status_code, - response_json=canonical, - provider_response_id=provider_id, - provider_model=provider_model, - usage=usage, - usable=_model_output_bytes(value) - <= self._profile.spec.maximum_output_bytes, - ) + return _openai_response(self._profile, response.status_code, value) except SetupProviderUncertain: raise except BaseException as exc: @@ -288,23 +272,7 @@ async def dispatch(self, body_json: str) -> SetupProviderResponse: ), provider_id if isinstance(provider_id, str) else None, ) - provider_model = value.get("model") - usage = _chat_usage(value.get("usage")) - if ( - not isinstance(provider_id, str) - or not provider_id - or provider_model != self._profile.spec.model_snapshot - ): - raise ValueError("provider response identity is invalid") - return SetupProviderResponse( - status_code=response.status_code, - response_json=_canonical_bytes(value).decode("utf-8"), - provider_response_id=provider_id, - provider_model=provider_model, - usage=usage, - usable=_chat_output_bytes(value) - <= self._profile.spec.maximum_output_bytes, - ) + return _openrouter_response(self._profile, response.status_code, value) except SetupProviderUncertain: raise except BaseException as exc: @@ -391,9 +359,13 @@ async def _handle(self, request: SetupModelProxyRequest) -> SetupModelProxyReply async with self._sessions() as session, session.begin(): authority = await self._authorization(session, subject.compilation_attempt_id) calls = self._calls(session, authority) - completed = await calls.recover_completed(key, request_sha) - if completed is not None: - return await self._cached(request, completed, profile) + existing = await calls.recover_existing(key, request_sha) + if isinstance(existing, ObservedSetupModelUsageEffect): + return await self._recover_observed(request, existing, profile) + if existing is not None: + if existing.state != "completed": + return _unavailable(request, "model_call_unavailable") + return await self._cached(request, existing, profile) async with self._provider(profile) as provider: input_tokens = await provider.count_input(body) @@ -428,12 +400,7 @@ async def _handle(self, request: SetupModelProxyRequest) -> SetupModelProxyReply authority = await self._authorization(session, dispatch.internal_call_request_id) await self._calls(session, authority).mark_uncertain( dispatch.effect, - canonical_json_hash( - { - "outcome": "provider_response_received", - "response_sha256": _sha(response_data), - } - ), + _sha(response_data), provider_response_id=response.provider_response_id, ) response_evidence = await self._store(prepared, "model_response", response_data) @@ -456,6 +423,71 @@ async def _handle(self, request: SetupModelProxyRequest) -> SetupModelProxyReply response_json=response.response_json, ) + async def _recover_observed( + self, + request: SetupModelProxyRequest, + observed: ObservedSetupModelUsageEffect, + profile: ProjectModelProfile, + ) -> SetupModelProxyReply: + """Complete only exact response bytes already verified by ART.""" + if observed.provider_response_id is None: + return _unavailable(request, "model_provider_delivery_uncertain") + effect = observed.effect + selector = SetupModelOutputSelector( + slot_key=effect.slot_key, + model_call_slot_id=effect.slot_id, + model_call_attempt_id=effect.effect_id, + role="model_response", + content_sha256=observed.uncertainty_digest, + ) + recovered = await self._outputs.recover_setup_output(selector) + if ( + recovered is None + or recovered.status != "verified" + or recovered.content_id is None + or recovered.verification_receipt_id is None + or recovered.content_sha256 != selector.content_sha256 + ): + return _unavailable(request, "model_provider_delivery_uncertain") + async with self._sessions() as session, session.begin(): + binding = await self._bindings(session).bind_setup_output( + SetupModelOutputBindingRequest( + selector, + recovered.put_attempt_id, + recovered.verification_receipt_id, + ) + ) + evidence = ModelCallEvidence( + binding_id=binding.binding_id, + content_id=binding.content_id, + put_attempt_id=binding.put_attempt_id, + verification_receipt_id=binding.verification_receipt_id, + content_sha256=recovered.content_sha256, + byte_count=recovered.byte_count, + ) + data = await self._outputs.read_setup_response(selector, evidence) + response = _retained_provider_response(profile, data) + if response.provider_response_id != observed.provider_response_id: + raise ModelCallUnavailable("setup_model_recovery_identity_mismatch") + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, effect.internal_call_request_id) + completed = await self._calls(session, authority).complete( + effect, + provider_response_id=response.provider_response_id, + provider_model_snapshot=response.provider_model, + response_evidence=evidence, + usage=response.usage, + usable=response.usable, + ) + if completed.state != "completed" or not response.usable: + return _unavailable(request, "model_response_too_large") + return _completed_reply( + request, + profile, + status_code=response.status_code, + response_json=response.response_json, + ) + async def _store( self, prepared: PreparedSetupModelUsageEffect, role: str, data: bytes ) -> ModelCallEvidence: @@ -729,6 +761,66 @@ def _nanodollars(value: object) -> int: return int(converted) +def _openai_response( + profile: ProjectModelProfile, status_code: int, value: dict[str, object] +) -> SetupProviderResponse: + provider_id = value.get("id") + provider_model = value.get("model") + if ( + not isinstance(provider_id, str) + or not provider_id + or provider_model != profile.spec.model_snapshot + ): + raise ValueError("provider response identity is invalid") + return SetupProviderResponse( + status_code=status_code, + response_json=_canonical_bytes(value).decode("utf-8"), + provider_response_id=provider_id, + provider_model=provider_model, + usage=_usage(value.get("usage")), + usable=_model_output_bytes(value) <= profile.spec.maximum_output_bytes, + ) + + +def _openrouter_response( + profile: ProjectModelProfile, status_code: int, value: dict[str, object] +) -> SetupProviderResponse: + provider_id = value.get("id") + provider_model = value.get("model") + if ( + not isinstance(provider_id, str) + or not provider_id + or provider_model != profile.spec.model_snapshot + ): + raise ValueError("provider response identity is invalid") + return SetupProviderResponse( + status_code=status_code, + response_json=_canonical_bytes(value).decode("utf-8"), + provider_response_id=provider_id, + provider_model=provider_model, + usage=_chat_usage(value.get("usage")), + usable=_chat_output_bytes(value) <= profile.spec.maximum_output_bytes, + ) + + +def _retained_provider_response( + profile: ProjectModelProfile, data: bytes +) -> SetupProviderResponse: + """Re-validate exact retained ART bytes without another provider request.""" + try: + value = json.loads(data, parse_constant=_reject_constant) + if not isinstance(value, dict) or _canonical_bytes(value) != data: + raise ValueError("retained response is not canonical") + identity = profile.spec.provider, profile.spec.api + if identity == ("openai", "responses"): + return _openai_response(profile, 200, value) + if identity == ("openrouter", "chat_completions"): + return _openrouter_response(profile, 200, value) + except (TypeError, ValueError, UnicodeError, RecursionError) as exc: + raise ModelCallUnavailable("setup_model_recovery_response_invalid") from exc + raise ModelCallUnavailable("model_profile_provider_unavailable") + + def _usage(value: object) -> ModelCallUsage: if not isinstance(value, dict): raise TypeError("provider usage is unavailable") diff --git a/backend/app/modules/artifacts/checker_outputs.py b/backend/app/modules/artifacts/checker_outputs.py index 470073fd4..10721fbfa 100644 --- a/backend/app/modules/artifacts/checker_outputs.py +++ b/backend/app/modules/artifacts/checker_outputs.py @@ -1,28 +1,38 @@ """Bounded checker bytes through the existing durable ART storage workflow.""" +import hashlib from collections.abc import Callable from uuid import UUID -import hashlib from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.core.config import Settings +from app.interfaces.artifacts import ArtifactByteRange, ArtifactIntegrityError, ArtifactStore from app.modules.artifacts.api.checker_output_authority import ( CheckerOutputWriteAuthority, ) +from app.modules.artifacts.checker_output_custody import select_checker_output +from app.modules.artifacts.models import ArtifactReplica, ArtifactVerificationJob +from app.modules.artifacts.preparation import ArtifactPreparationService +from app.modules.artifacts.schemas import ( + ArtifactInternalAuthority, + CheckerOutputArtifactAdmissionRequest, +) +from app.modules.artifacts.service import ( + ArtifactAdmissionService, + ArtifactStorageNamespaceSpec, + ArtifactStorageOrchestrator, +) from app.modules.checkers.api.output_custody import ( + GENERATED_OUTPUT_SELECTOR, CheckerOutputArtifactRequest, CheckerOutputArtifactResult, CheckerOutputReservationPort, CheckerOutputSelector, CheckerOutputUnavailable, GeneratedOutputSelector, - GENERATED_OUTPUT_SELECTOR, ) -from app.interfaces.artifacts import ArtifactStore, ArtifactByteRange, ArtifactIntegrityError -from app.modules.artifacts.checker_output_custody import select_checker_output -from app.modules.artifacts.models import ArtifactVerificationJob, ArtifactReplica from app.modules.model_usage.api import ( ModelCallEvidence, SetupModelOutputArtifactRequest, @@ -30,16 +40,6 @@ SetupModelOutputReservationPort, SetupModelOutputSelector, ) -from app.modules.artifacts.preparation import ArtifactPreparationService -from app.modules.artifacts.schemas import ( - ArtifactInternalAuthority, - CheckerOutputArtifactAdmissionRequest, -) -from app.modules.artifacts.service import ( - ArtifactAdmissionService, - ArtifactStorageOrchestrator, - ArtifactStorageNamespaceSpec, -) class CheckerArtifactOutputService: @@ -83,6 +83,8 @@ async def store_setup_output( result.status, result.content_id, result.verification_receipt_id, + result.content_sha256, + result.byte_count, result.replayed, ) @@ -159,6 +161,8 @@ async def recover_setup_output( result.status, result.content_id, result.verification_receipt_id, + result.content_sha256, + result.byte_count, result.replayed, ) ) @@ -329,5 +333,7 @@ async def _result( facts.status, facts.content_id, facts.verification_receipt_id, + facts.sha256, + facts.byte_count, replayed, ) diff --git a/backend/app/modules/checkers/api/output_custody.py b/backend/app/modules/checkers/api/output_custody.py index 736cac193..569a09d61 100644 --- a/backend/app/modules/checkers/api/output_custody.py +++ b/backend/app/modules/checkers/api/output_custody.py @@ -8,18 +8,17 @@ from pydantic import Field, StrictInt, TypeAdapter, model_validator from app.core.hashing import canonical_json_hash - +from app.modules.checkers.api.canonical_values import ( + CheckerValue, + ResourceId, +) from app.modules.model_usage.api import ( + ModelCallEvidence, ModelCallExecutionFacts, ModelPaidSlotKey, - ModelCallEvidence, SetupModelOutputReservation, SetupModelOutputSelector, ) -from app.modules.checkers.api.canonical_values import ( - CheckerValue, - ResourceId, -) class CheckerOutputUnavailable(RuntimeError): @@ -131,6 +130,8 @@ class CheckerOutputArtifactResult: status: str content_id: UUID | None verification_receipt_id: UUID | None + content_sha256: str + byte_count: int replayed: bool diff --git a/backend/app/modules/model_usage/api.py b/backend/app/modules/model_usage/api.py index 919dae9bf..188c0c795 100644 --- a/backend/app/modules/model_usage/api.py +++ b/backend/app/modules/model_usage/api.py @@ -634,8 +634,14 @@ class SetupModelOutputArtifactResult: status: str content_id: UUID | None verification_receipt_id: UUID | None + content_sha256: Sha256 + byte_count: int replayed: bool + def __post_init__(self) -> None: + if type(self.byte_count) is not int or not 0 <= self.byte_count <= 1_048_576: + raise ValueError("setup model output byte count is invalid") + @dataclass(frozen=True, slots=True) class SetupModelOutputBindingResult: diff --git a/backend/app/modules/model_usage/setup.py b/backend/app/modules/model_usage/setup.py index 03868c6bb..c20d09bde 100644 --- a/backend/app/modules/model_usage/setup.py +++ b/backend/app/modules/model_usage/setup.py @@ -204,12 +204,16 @@ async def prepare( await self._repo.add(effect) return _prepared(envelope, slot, effect, key, replayed=False) - async def recover_completed( + async def recover_existing( self, key: SetupModelUsageSlotKey, request_sha256: str - ) -> CompletedSetupModelUsageEffect | None: - """Return one exact completed response without recreating provider I/O.""" + ) -> CompletedSetupModelUsageEffect | ObservedSetupModelUsageEffect | None: + """Return a retained terminal or uncertain effect without recreating I/O.""" require_model_call_transaction(self._session) key = SetupModelUsageSlotKey.model_validate(key) + request_sha256 = _sha(request_sha256) + facts = {"slot": key.model_dump(mode="json"), "request_sha256": request_sha256} + authority = await self._authority.authorize_setup_prepare(key, request_sha256) + _require_authority("setup_prepare", facts, authority) owner_profile = await self._owner.require_setup_current(key) await self._profile(key, owner_profile) await self._repo.lock_setup_envelope_scope(key) @@ -225,7 +229,16 @@ async def recover_completed( effect = await self._repo.latest_setup_effect(slot.id, lock=True) if effect is None or effect.state == "prepared": return None - if effect.state != "completed": + if effect.state == "uncertain": + if effect.uncertainty_digest is None: + raise ModelCallUnavailable("setup_model_effect_not_replayable") + return ObservedSetupModelUsageEffect( + effect=_effect(slot, effect), + uncertainty_digest=effect.uncertainty_digest, + provider_response_id=effect.provider_response_id, + replayed=True, + ) + if effect.state not in {"completed", "known_failed"}: raise ModelCallUnavailable("setup_model_effect_not_replayable") return self._completed(effect, slot, replayed=True) diff --git a/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py index 231cfb404..75f7bd962 100644 --- a/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py @@ -480,6 +480,10 @@ async def test_uncertain_setup_effect_replay_never_reauthorizes_dispatch( async with AsyncSession(model_database.engine, expire_on_commit=False) as session: async with session.begin(): owner = setup_service(session, ports) + observed = await owner.recover_existing(key, request_sha) + assert observed is not None + assert observed.state == "uncertain" + assert observed.replayed is True replayed = await owner.prepare(key, request_sha) with pytest.raises(ModelCallConflict, match="not_dispatchable"): await owner.reserve_dispatch(replayed, request_evidence) diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler.py b/backend/tests/checkers_model_calls/test_setup_model_handler.py index 018aaeaba..84d4e9f14 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_handler.py +++ b/backend/tests/checkers_model_calls/test_setup_model_handler.py @@ -23,6 +23,7 @@ from app.modules.model_usage.api import ( ModelCallUnavailable, ModelCallUsage, + ObservedSetupModelUsageEffect, ProjectModelProfile, SetupModelEffectAuthorityFacts, SetupModelUsageSlotKey, @@ -408,10 +409,12 @@ async def read_exact(self, *_args, **_kwargs): class Calls: completed = None + observed = None + fail_completion = True - async def recover_completed(self, *_args): + async def recover_existing(self, *_args): events.append("recover") - return self.completed + return self.completed or self.observed async def prepare(self, *_args): events.append("prepare") @@ -421,15 +424,26 @@ async def reserve_dispatch(self, *_args): events.append("reserve") return dispatch - async def mark_uncertain(self, *_args, provider_response_id=None): + async def mark_uncertain(self, supplied, digest, *, provider_response_id=None): events.append(("observe", provider_response_id)) + self.observed = ObservedSetupModelUsageEffect( + effect=supplied, + uncertainty_digest=digest, + provider_response_id=provider_response_id, + replayed=False, + ) async def complete(self, *_args, response_evidence, **_kwargs): events.append("complete") + if self.fail_completion: + self.fail_completion = False + raise ModelCallUnavailable("simulated_response_commit_loss") self.completed = SimpleNamespace( effect=effect, response_evidence=response_evidence, + state="completed", ) + self.observed = None return self.completed calls = Calls() @@ -442,9 +456,22 @@ async def store_setup_output(self, artifact): return SimpleNamespace( status="verified", put_attempt_id=new_record_id(), + content_id=new_record_id(), verification_receipt_id=new_record_id(), ) + async def recover_setup_output(self, selector): + events.append("recover_response_art") + data = stored["model_response"] + return SimpleNamespace( + status="verified", + put_attempt_id=new_record_id(), + content_id=new_record_id(), + verification_receipt_id=new_record_id(), + content_sha256=selector.content_sha256, + byte_count=len(data), + ) + async def read_setup_response(self, *_args): events.append("read_response") return stored["model_response"] @@ -506,13 +533,23 @@ async def provider(_profile): provider=provider, ) - assert (await handler.handle(request)).outcome == "completed" + assert (await handler.handle(request)).outcome == "unavailable" first_events = list(events) assert first_events.index(("observe", "response-1")) < first_events.index( "store_model_response" ) assert first_events[-1] == "complete" + assert (await handler.handle(request)).outcome == "completed" + assert provider_calls == 1 + assert events[-5:] == [ + "recover", + "recover_response_art", + "bind_model_response", + "read_response", + "complete", + ] + assert (await handler.handle(request)).outcome == "completed" assert provider_calls == 1 assert events[-2:] == ["recover", "read_response"] diff --git a/backend/tests/test_artifact_architecture.py b/backend/tests/test_artifact_architecture.py index 2aa922353..d0aa7bb6f 100644 --- a/backend/tests/test_artifact_architecture.py +++ b/backend/tests/test_artifact_architecture.py @@ -8,12 +8,11 @@ from pathlib import Path import pytest -from tests.architecture_ast import imported_symbols_and_calls -from app.interfaces import artifact_operations from app.db.base import Base +from app.interfaces import artifact_operations from app.main import create_app - +from tests.architecture_ast import imported_symbols_and_calls BACKEND_ROOT = Path(__file__).parents[1] APP_ROOT = BACKEND_ROOT / "app" @@ -754,6 +753,7 @@ def _assert_checker_custody_consumers( expected = { "app.modules.checkers.api.output_custody": { "app/adapters/artifacts/__init__.py", + "app/adapters/checkers/model_proxy.py", "app/modules/artifacts/api/checker_output_authority.py", "app/modules/artifacts/checker_output_bindings.py", "app/modules/artifacts/checker_output_custody.py", @@ -762,8 +762,7 @@ def _assert_checker_custody_consumers( "app/modules/artifacts/service.py", "app/modules/authorization/checker_output_authorization.py", "app/modules/authorization/domain/checker_outputs.py", - "app/modules/checkers/model_calls/custody.py", - "app/modules/checkers/model_calls/proxy.py", + "app/modules/checkers/model_usage_custody.py", "app/modules/tasks/post_submit_routing/evaluation_guard.py", }, "app.modules.checkers.api.materialization": { From fabeaa1457dc6192fd6c2a58455b48db28428211 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 20:54:19 +0100 Subject: [PATCH 022/115] Prove setup response recovery from artifact custody --- .../test_setup_model_handler_postgresql.py | 469 ++++++++++-------- 1 file changed, 261 insertions(+), 208 deletions(-) diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py index 2813ca7eb..12c3fe0ad 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py +++ b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py @@ -2,8 +2,10 @@ from __future__ import annotations +from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from dataclasses import asdict, replace +from dataclasses import asdict, dataclass, replace +import json import os from uuid import UUID, uuid4 @@ -54,6 +56,7 @@ ProjectGuideCompilationRequestOrigin, ) from app.modules.model_usage.api import ( + ModelCallUnavailable, ModelCallUsage, ProjectModelProfile, ProjectModelProfileSpec, @@ -128,7 +131,7 @@ async def _publish_profile(access, project_id: UUID) -> ProjectModelProfile: }, ) assert budget.status_code == 201, budget.text - return ProjectModelProfile.model_validate(response.json()) + return ProjectModelProfile.model_validate_json(json.dumps(response.json())) def _runtime(profile: ProjectModelProfile): @@ -200,13 +203,22 @@ def _provider_request(profile: ProjectModelProfile) -> str: ) -async def test_setup_request_and_response_use_verified_minio_custody_and_replay( - tmp_path, - isolated_database_env, - admin_access, # noqa: F811 - imported fixture is intentionally requested here - monkeypatch, -) -> None: - """Persist both private payloads in ART and replay without another provider send.""" +@dataclass(slots=True) +class _StorageCase: + database_url: str + factory: object + engine: object + bootstrap: object + manager: ArtifactScratchManager + settings: object + namespace: object + store: object + values: dict + + +@asynccontextmanager +async def _storage_case(tmp_path, database_url, monkeypatch) -> AsyncIterator[_StorageCase]: + """Own one real MinIO namespace and its exact PostgreSQL namespace row.""" from tests import test_s3_artifact_store from tests.test_s3_artifact_store import minio_settings, provision_minio_bucket @@ -236,11 +248,11 @@ async def test_setup_request_and_response_use_verified_minio_custody_and_replay( root=tmp_path / "setup-model-preparation", limits=artifact_preparation_limits(), ) - engine = create_async_engine(isolated_database_env) + engine = create_async_engine(database_url) factory = async_sessionmaker(engine, expire_on_commit=False) try: values = await seed_database( - isolated_database_env, + database_url, namespace=namespace, provider_object_ref="fixture/guide-source-not-read", ) @@ -250,210 +262,251 @@ async def test_setup_request_and_response_use_verified_minio_custody_and_replay( ServiceIdentity.ARTIFACT_VERIFIER, ): await provision_post_submit_service(factory, service_identity) - profile = await _publish_profile(admin_access, values["project"]) - configuration = _runtime(profile) - requested = await _fence_setup_attempt( - isolated_database_env, values, configuration - ) - attempt = context(values, configuration=configuration) - subject = SetupModelProxySubject( - compilation_attempt_id=requested.attempt_id, - project_id=values["project"], - guide_id=values["guide"], - source_snapshot_id=values["snapshot"], - setup_run_id=values["setup_1"], - setup_generation=1, - runtime_configuration_hash=configuration.sha256, - profile_id=profile.profile_id, - profile_version=profile.spec.profile_version, - profile_digest=profile.profile_digest, - provider=profile.spec.provider, - api=profile.spec.api, - model=profile.spec.model_snapshot, - ) - assert attempt.setup_run_id == subject.setup_run_id - request = SetupModelProxyRequest( - subject=subject, - phase="draft", - call_index=0, - provider_request_json=_provider_request(profile), + yield _StorageCase( + database_url, + factory, + engine, + bootstrap, + manager, + settings, + namespace, + store, + values, ) - response_marker = f"private-setup-response-{uuid4()}" - response_json = canonical_provider_json( - { - "id": "setup-response-1", - "model": profile.spec.model_snapshot, - "choices": [{"message": {"content": response_marker}}], - "usage": {"prompt_tokens": 1, "completion_tokens": 1}, - } - ) - provider_calls = 0 - reserve_observations: list[tuple[str, str, str]] = [] - - class Provider: - async def count_input(self, _body): - return 1 - - async def dispatch(self, _body): - nonlocal provider_calls - provider_calls += 1 - assert reserve_observations == [ - ("setup_model_call", "model_request", "verified") - ] - return SetupProviderResponse( - status_code=200, - response_json=response_json, - provider_response_id="setup-response-1", - provider_model=profile.spec.model_snapshot, - usage=ModelCallUsage( - input_tokens=1, - cached_input_tokens=0, - output_tokens=1, - reasoning_tokens=0, - ), - ) - - @asynccontextmanager - async def provider(_profile): - yield Provider() - - def calls(session, authority): - owner = setup_model_usage_service( + finally: + manager.close() + bootstrap.close() + await engine.dispose() + + +@dataclass(slots=True) +class _Journey: + storage: _StorageCase + profile: ProjectModelProfile + request: SetupModelProxyRequest + response_json: str + response_marker: str + provider_calls: list[str] + reserve_observations: list[tuple[str, str, str]] + fail_completion: list[bool] + outputs: object + + def handler(self) -> SetupModelProxyHandler: + return SetupModelProxyHandler( + sessions=self.storage.factory, + authorization=setup_model_service_authorization, + calls=lambda session, authority: _ObservedCalls(session, authority, self), + profiles=model_profile_reader, + outputs=self.outputs, + bindings=lambda session: setup_model_output_binding( session, - authority=authority, - evidence=setup_model_evidence(session), + namespace=self.storage.namespace, owner=setup_model_usage_owner(session), - ) + ), + provider=lambda profile: _provider(self, profile), + ) - class ObservedCalls: - def __getattr__(self, name): - return getattr(owner, name) - - async def reserve_dispatch(self, prepared, request_evidence): - row = ( - await session.execute( - text( - "select binding.resource_type,binding.logical_role,receipt.outcome " - "from artifact_bindings binding " - "join artifact_verification_receipts receipt " - "on receipt.id=binding.verification_receipt_id " - "where binding.id=:binding and binding.resource_id=:effect" - ), - { - "binding": str(request_evidence.binding_id), - "effect": str(prepared.effect_id), - }, - ) - ).one() - reserve_observations.append(tuple(row)) - return await owner.reserve_dispatch(prepared, request_evidence) - - return ObservedCalls() - - preparation = ArtifactPreparationService(manager) - outputs = setup_model_output_storage( - sessions=factory, - store=store, - namespace=namespace, - preparation=preparation, - settings=settings, - owner=setup_model_usage_owner, + +class _Provider: + def __init__(self, journey: _Journey) -> None: + self.journey = journey + + async def count_input(self, _body): + return 1 + + async def dispatch(self, _body): + self.journey.provider_calls.append("dispatch") + assert self.journey.reserve_observations == [ + ("setup_model_call", "model_request", "verified") + ] + return SetupProviderResponse( + status_code=200, + response_json=self.journey.response_json, + provider_response_id="setup-response-1", + provider_model=self.journey.profile.spec.model_snapshot, + usage=ModelCallUsage( + input_tokens=1, + cached_input_tokens=0, + output_tokens=1, + reasoning_tokens=0, + ), + ) + + +@asynccontextmanager +async def _provider(journey: _Journey, _profile) -> AsyncIterator[_Provider]: + yield _Provider(journey) + + +class _ObservedCalls: + def __init__(self, session, authority, journey: _Journey) -> None: + self.session = session + self.journey = journey + self.owner = setup_model_usage_service( + session, + authority=authority, + evidence=setup_model_evidence(session), + owner=setup_model_usage_owner(session), ) - def handler() -> SetupModelProxyHandler: - return SetupModelProxyHandler( - sessions=factory, - authorization=setup_model_service_authorization, - calls=calls, - profiles=model_profile_reader, - outputs=outputs, - bindings=lambda session: setup_model_output_binding( - session, - namespace=namespace, - owner=setup_model_usage_owner(session), + def __getattr__(self, name): + return getattr(self.owner, name) + + async def reserve_dispatch(self, prepared, request_evidence): + row = ( + await self.session.execute( + text( + "select binding.resource_type,binding.logical_role,receipt.outcome " + "from artifact_bindings binding join artifact_verification_receipts receipt " + "on receipt.id=binding.verification_receipt_id " + "where binding.id=:binding and binding.resource_id=:effect" ), - provider=provider, + { + "binding": str(request_evidence.binding_id), + "effect": str(prepared.effect_id), + }, ) + ).one() + self.journey.reserve_observations.append(tuple(row)) + return await self.owner.reserve_dispatch(prepared, request_evidence) + + async def complete(self, *args, **kwargs): + if self.journey.fail_completion[0]: + self.journey.fail_completion[0] = False + raise ModelCallUnavailable("simulated_setup_ledger_unavailable") + return await self.owner.complete(*args, **kwargs) + + +async def _journey(storage: _StorageCase, access) -> _Journey: + profile = await _publish_profile(access, storage.values["project"]) + configuration = _runtime(profile) + requested = await _fence_setup_attempt( + storage.database_url, storage.values, configuration + ) + subject = SetupModelProxySubject( + compilation_attempt_id=requested.attempt_id, + project_id=storage.values["project"], + guide_id=storage.values["guide"], + source_snapshot_id=storage.values["snapshot"], + setup_run_id=storage.values["setup_1"], + setup_generation=1, + runtime_configuration_hash=configuration.sha256, + profile_id=profile.profile_id, + profile_version=profile.spec.profile_version, + profile_digest=profile.profile_digest, + provider=profile.spec.provider, + api=profile.spec.api, + model=profile.spec.model_snapshot, + ) + request = SetupModelProxyRequest( + subject, "draft", 0, _provider_request(profile) + ) + marker = f"private-setup-response-{uuid4()}" + response = canonical_provider_json( + { + "id": "setup-response-1", + "model": profile.spec.model_snapshot, + "choices": [{"message": {"content": marker}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1}, + } + ) + outputs = setup_model_output_storage( + sessions=storage.factory, + store=storage.store, + namespace=storage.namespace, + preparation=ArtifactPreparationService(storage.manager), + settings=storage.settings, + owner=setup_model_usage_owner, + ) + return _Journey(storage, profile, request, response, marker, [], [], [True], outputs) + + +async def _state(journey: _Journey): + async with journey.storage.factory() as session: + return ( + await session.execute( + text( + "select effect.id,effect.state,effect.request_binding_id," + "effect.response_binding_id,effect.settled_nanodollars," + "account.lifetime_reserved_nanodollars," + "account.lifetime_settled_nanodollars " + "from model_usage_effects effect " + "join setup_model_usage_slots slot on slot.id=effect.setup_slot_id " + "join model_usage_budget_accounts account on account.project_id=slot.project_id " + "where slot.compilation_attempt_id=:attempt" + ), + {"attempt": str(journey.request.subject.compilation_attempt_id)}, + ) + ).one() + + +async def _assert_bound_private_art(journey: _Journey, state) -> None: + async with journey.storage.factory() as session: + bindings = ( + await session.execute( + text( + "select binding.logical_role,binding.resource_id,receipt.outcome " + "from artifact_bindings binding join artifact_verification_receipts receipt " + "on receipt.id=binding.verification_receipt_id " + "where binding.resource_type='setup_model_call' " + "and binding.resource_id=:effect order by binding.logical_role" + ), + {"effect": str(state.id)}, + ) + ).all() + assert [tuple(row) for row in bindings] == [ + ("model_request", str(state.id), "verified"), + ("model_response", str(state.id), "verified"), + ] + for table in ( + "model_usage_effects", + "artifact_contents", + "artifact_put_attempts", + "artifact_operation_receipts", + "artifact_verification_receipts", + "artifact_bindings", + "audit_events", + ): + leaked = await session.scalar( + text( + f"select exists(select 1 from {table} row " + "where to_jsonb(row)::text like :needle)" + ), + {"needle": f"%{journey.response_marker}%"}, + ) + assert leaked is False, table - first = await handler().handle(request) - assert first.outcome == "completed" - assert first.response_json == response_json - assert provider_calls == 1 - async with factory() as session: - state = ( - await session.execute( - text( - "select effect.id,effect.state,effect.request_binding_id," - "effect.response_binding_id,effect.settled_nanodollars," - "account.lifetime_reserved_nanodollars," - "account.lifetime_settled_nanodollars " - "from model_usage_effects effect " - "join setup_model_usage_slots slot on slot.id=effect.setup_slot_id " - "join model_usage_budget_accounts account on account.project_id=slot.project_id " - "where slot.compilation_attempt_id=:attempt" - ), - {"attempt": str(requested.attempt_id)}, - ) - ).one() - bindings = ( - await session.execute( - text( - "select binding.logical_role,binding.resource_id,receipt.outcome " - "from artifact_bindings binding " - "join artifact_verification_receipts receipt " - "on receipt.id=binding.verification_receipt_id " - "where binding.resource_type='setup_model_call' " - "and binding.resource_id=:effect order by binding.logical_role" - ), - {"effect": str(state.id)}, - ) - ).all() - assert [tuple(row) for row in bindings] == [ - ("model_request", str(state.id), "verified"), - ("model_response", str(state.id), "verified"), - ] - assert state.state == "completed" - assert state.request_binding_id is not None - assert state.response_binding_id is not None - assert state.lifetime_reserved_nanodollars == 0 - assert state.lifetime_settled_nanodollars == state.settled_nanodollars - settled = state.lifetime_settled_nanodollars - for table in ( - "model_usage_effects", - "artifact_contents", - "artifact_put_attempts", - "artifact_operation_receipts", - "artifact_verification_receipts", - "artifact_bindings", - "audit_events", - ): - leaked = await session.scalar( - text( - f"select exists(select 1 from {table} row " - "where to_jsonb(row)::text like :needle)" - ), - {"needle": f"%{response_marker}%"}, - ) - assert leaked is False, table - - replay = await handler().handle(request) - assert replay == first - assert provider_calls == 1 - async with factory() as session: - replay_state = ( - await session.execute( - text( - "select count(*) as effects,min(state) as state," - "min(settled_nanodollars) as settled from model_usage_effects effect " - "join setup_model_usage_slots slot on slot.id=effect.setup_slot_id " - "where slot.compilation_attempt_id=:attempt" - ), - {"attempt": str(requested.attempt_id)}, - ) - ).one() - assert replay_state == (1, "completed", settled) - finally: - manager.close() - bootstrap.close() - await engine.dispose() +async def test_setup_response_recovers_from_verified_minio_after_ledger_loss( + tmp_path, + isolated_database_env, + admin_access, # noqa: F811 - imported fixture is intentionally requested here + monkeypatch, +) -> None: + """Recover exact response ART after a crash boundary without a second send.""" + async with _storage_case(tmp_path, isolated_database_env, monkeypatch) as storage: + journey = await _journey(storage, admin_access) + first = await journey.handler().handle(journey.request) + assert first.outcome == "unavailable" + uncertain = await _state(journey) + assert uncertain.state == "uncertain" + assert uncertain.response_binding_id is None + assert uncertain.lifetime_reserved_nanodollars > 0 + assert uncertain.lifetime_settled_nanodollars == 0 + await _assert_bound_private_art(journey, uncertain) + + recovered = await journey.handler().handle(journey.request) + assert recovered.outcome == "completed" + assert recovered.response_json == journey.response_json + completed = await _state(journey) + assert completed.state == "completed" + assert completed.request_binding_id is not None + assert completed.response_binding_id is not None + assert completed.lifetime_reserved_nanodollars == 0 + assert completed.lifetime_settled_nanodollars == completed.settled_nanodollars + await _assert_bound_private_art(journey, completed) + + replay = await journey.handler().handle(journey.request) + assert replay == recovered + assert journey.provider_calls == ["dispatch"] + assert await _state(journey) == completed From 7a9f81cb6d246e40e859f93cc8ab908f13de1d32 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 21:09:06 +0100 Subject: [PATCH 023/115] feat(model-usage): inspect exact paid effects --- .commitrail/changes/checker-model-runtime.md | 14 ++ backend/alembic/versions/0061_model_usage.py | 1 + backend/app/adapters/model_usage/__init__.py | 7 + backend/app/api/deps/checker_models.py | 43 ++++- .../authorization/action_definitions.py | 5 + .../app/modules/authorization/catalogue.py | 4 +- .../authorization/domain/model_usage.py | 11 +- .../model_usage_authorization.py | 18 ++ backend/app/modules/checkers/router.py | 98 +++++++---- backend/app/modules/model_usage/api.py | 79 ++++++++- backend/app/modules/model_usage/inspection.py | 152 +++++++++++++++++ .../tests/authorization/catalogue_fixtures.py | 110 ++++++++---- backend/tests/authorization/test_catalogue.py | 4 +- .../test_model_usage_inspection.py | 160 ++++++++++++++++++ .../checkers_model_calls/test_public_api.py | 7 + docs/operations_roles_permissions.md | 1 + docs/spec_authorization_service.md | 6 +- .../contracts/authorization_context_get.json | 3 +- 18 files changed, 649 insertions(+), 74 deletions(-) create mode 100644 backend/app/modules/model_usage/inspection.py create mode 100644 backend/tests/checkers_model_calls/test_model_usage_inspection.py diff --git a/.commitrail/changes/checker-model-runtime.md b/.commitrail/changes/checker-model-runtime.md index 28388dc52..5743f06b4 100644 --- a/.commitrail/changes/checker-model-runtime.md +++ b/.commitrail/changes/checker-model-runtime.md @@ -324,6 +324,20 @@ schema inventory/fingerprint updates. This continuation does not edit PROJECTS owners, or CHECKERS ORM. It adds only the closed setup-model-output case; independent trial-evidence types remain outside this branch. +### Exact effect inspection + +The shared owner exposes one bounded System Operator read at +`GET /api/v1/operations/projects/{project_id}/model-usage/effects/{effect_id}`. +Fresh `project.model_usage.read` authority reuses the existing +`project.model_budget.manage` permission and binds the request, actor, project +and exact effect before MODEL-USAGE is read. Missing and foreign effects are +concealed. The response contains only the typed stable setup/checker owner, +profile selector, state, reservations/settlement, usage and ART evidence +identities; it never returns provider response coordinates, request/response +bytes, credentials, list access or mutation authority. The action/permission +pair is added to the same linear `0061_model_usage` audit allowlist and the MCP +authorization-context snapshot. + ## Independent QA, test-delta and CI review Review dimensions: `qa`, `test_delta`, and `ci_integrity`. Review target: diff --git a/backend/alembic/versions/0061_model_usage.py b/backend/alembic/versions/0061_model_usage.py index bad0ae333..e8f20b642 100644 --- a/backend/alembic/versions/0061_model_usage.py +++ b/backend/alembic/versions/0061_model_usage.py @@ -982,6 +982,7 @@ def _upgrade_authorization_vocabulary() -> None: ), ("project.model_profile.provision", "project.model_profile.manage"), ("project.model_budget.configure", "project.model_budget.manage"), + ("project.model_usage.read", "project.model_budget.manage"), ( "project.model_call.recovery_child.authorize", "project.model_call.recovery_child.authorize", diff --git a/backend/app/adapters/model_usage/__init__.py b/backend/app/adapters/model_usage/__init__.py index ea2f00c46..f076bbcb1 100644 --- a/backend/app/adapters/model_usage/__init__.py +++ b/backend/app/adapters/model_usage/__init__.py @@ -7,6 +7,12 @@ def model_profile_reader(session): return ModelProfileReader(session) +def model_usage_inspection(session, *, authority): + from app.modules.model_usage.inspection import ModelUsageInspectionService + + return ModelUsageInspectionService(session, authority) + + def model_call_service(session, *, authority, current_execution, evidence): from app.modules.model_usage.service import ModelCallService @@ -63,6 +69,7 @@ def setup_model_output_reservations(session, *, owner): "model_evidence_custody", "model_invocation_grants", "model_profile_reader", + "model_usage_inspection", "model_recovery_sources", "setup_model_usage_service", "setup_model_output_reservations", diff --git a/backend/app/api/deps/checker_models.py b/backend/app/api/deps/checker_models.py index f9bb789cc..fd1ae184b 100644 --- a/backend/app/api/deps/checker_models.py +++ b/backend/app/api/deps/checker_models.py @@ -14,6 +14,7 @@ from app.adapters.auth import checker_model_authorization from app.adapters.checkers import model_call_service +from app.adapters.model_usage import model_usage_inspection from app.api.deps.auth import get_auth_verification_result from app.api.deps.authorization import ( ResolvedActor, @@ -30,6 +31,9 @@ ModelCallConflict, ModelCallUnavailable, ModelProfileProvisioningFacts, + ModelUsageEffectInspection, + ModelUsageInspectionFacts, + ModelUsageInspectionPort, ModelRecoveryChildRequest, PreparedModelCallAttempt, ProjectModelBudgetConfiguration, @@ -94,6 +98,7 @@ class CheckerModelAdminCommands: service: ModelCallAdministrationPort actor_profile_id: UUID operation_id: UUID + inspection: ModelUsageInspectionPort | None = None async def provision_profile( self, project_id: UUID, spec: ProjectModelProfileSpec @@ -123,6 +128,18 @@ async def configure_budget( ) ) + async def inspect_effect(self, project_id: UUID, effect_id: UUID) -> ModelUsageEffectInspection: + if self.inspection is None: + raise ModelCallUnavailable("model_usage_inspection_unavailable") + return await self.inspection.inspect_effect( + ModelUsageInspectionFacts( + project_id=project_id, + effect_id=effect_id, + actor_profile_id=self.actor_profile_id, + request_id=self.operation_id, + ) + ) + async def prepare_recovery_child( self, project_id: UUID, @@ -158,6 +175,7 @@ async def _model_admin_operation( try: yield CheckerModelAdminCommands( service=model_call_service(session, authority), + inspection=model_usage_inspection(session, authority=authority), actor_profile_id=context.actor_profile_id, operation_id=operation_id, ) @@ -172,7 +190,12 @@ async def _model_admin_operation( except ModelCallConflict as exc: raise _error(409) from exc except ModelCallUnavailable as exc: - status_code = 404 if str(exc) == "model_call_authority_unavailable" else 503 + status_code = ( + 404 + if str(exc) + in {"model_call_authority_unavailable", "model_usage_inspection_unavailable"} + else 503 + ) raise _error(status_code) from exc except SQLAlchemyError as exc: raise _error(503) from exc @@ -209,11 +232,29 @@ async def get_checker_model_recovery_commands( yield commands +async def get_checker_model_read_commands( + request: Request, + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> AsyncIterator[CheckerModelAdminCommands]: + """Compose one exact human Operator model-usage read.""" + request_id, correlation_id = (UUID(value) for value in request_ids(request)) + async with _model_admin_operation( + request, + request_id, + resolved, + session, + correlation_id=correlation_id, + ) as commands: + yield commands + + __all__ = ( "MODEL_ADMIN_IDEMPOTENCY_PARAMETER", "CheckerModelAdminCommands", "get_checker_model_admin_commands", "get_checker_model_recovery_commands", + "get_checker_model_read_commands", "require_model_admin_human", "require_model_admin_key", "require_model_recovery_human", diff --git a/backend/app/modules/authorization/action_definitions.py b/backend/app/modules/authorization/action_definitions.py index ee77dddfb..84e105e22 100644 --- a/backend/app/modules/authorization/action_definitions.py +++ b/backend/app/modules/authorization/action_definitions.py @@ -676,6 +676,11 @@ PermissionId.PROJECT_MODEL_BUDGET_MANAGE, ActionOwner.PILOT_05, ), + _active( + ActionId.PROJECT_MODEL_USAGE_READ, + PermissionId.PROJECT_MODEL_BUDGET_MANAGE, + ActionOwner.PILOT_05, + ), _active( ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, PermissionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index bda4b8101..56aa69e9a 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -181,6 +181,7 @@ class ActionId(StrEnum): PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE = "project.guide_compilation.model.complete" PROJECT_MODEL_PROFILE_PROVISION = "project.model_profile.provision" PROJECT_MODEL_BUDGET_CONFIGURE = "project.model_budget.configure" + PROJECT_MODEL_USAGE_READ = "project.model_usage.read" PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE = "project.model_call.recovery_child.authorize" OUTBOX_DISPATCH = "outbox.dispatch" ACTOR_SERVICE_PROVISION = "actor.service.provision" @@ -564,7 +565,7 @@ def _active( def _require_catalogue_counts() -> None: """Keep the closed action inventory and permission boundary exact.""" - if len(PERMISSION_IDS) != 95 or len(ACTION_IDS) != 190: + if len(PERMISSION_IDS) != 95 or len(ACTION_IDS) != 191: raise RuntimeError("authorization catalogue count mismatch") if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 46: raise RuntimeError("authorization permission boundary mismatch") @@ -718,6 +719,7 @@ def _validate_action_definitions(definitions: tuple[ActionDefinition, ...]) -> N ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, ActionId.PROJECT_MODEL_PROFILE_PROVISION, ActionId.PROJECT_MODEL_BUDGET_CONFIGURE, + ActionId.PROJECT_MODEL_USAGE_READ, ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, ActionId.CHECKER_PRE_SUBMIT_EXECUTE, ActionId.CHECKER_PRE_SUBMIT_FINALIZE, diff --git a/backend/app/modules/authorization/domain/model_usage.py b/backend/app/modules/authorization/domain/model_usage.py index b878bbf66..93ad4d80f 100644 --- a/backend/app/modules/authorization/domain/model_usage.py +++ b/backend/app/modules/authorization/domain/model_usage.py @@ -16,6 +16,7 @@ ModelCallExecutionFacts, ModelPaidSlotKey, ModelProfileProvisioningFacts, + ModelUsageInspectionFacts, PreparedModelCallAttempt, PreparedSetupModelUsageEffect, SetupModelEffectAuthorityFacts, @@ -26,6 +27,7 @@ PROFILE_PROVISION = ActionId.PROJECT_MODEL_PROFILE_PROVISION BUDGET_CONFIGURE = ActionId.PROJECT_MODEL_BUDGET_CONFIGURE +USAGE_READ = ActionId.PROJECT_MODEL_USAGE_READ RECOVERY_CHILD_AUTHORIZE = ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE PREPARE = ActionId.CHECKER_MODEL_CALL_PREPARE DISPATCH = ActionId.CHECKER_MODEL_CALL_DISPATCH @@ -38,7 +40,9 @@ SETUP_OBSERVE = ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE SETUP_COMPLETE = ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE -MODEL_ADMIN_ACTIONS = frozenset({PROFILE_PROVISION, BUDGET_CONFIGURE, RECOVERY_CHILD_AUTHORIZE}) +MODEL_ADMIN_ACTIONS = frozenset( + {PROFILE_PROVISION, BUDGET_CONFIGURE, USAGE_READ, RECOVERY_CHILD_AUTHORIZE} +) CHECKER_MODEL_CALL_ACTIONS = frozenset({PREPARE, DISPATCH, RECOVER, OBSERVE, COMPLETE, GRANT}) SETUP_MODEL_USAGE_ACTIONS = frozenset( {SETUP_PREPARE, SETUP_DISPATCH, SETUP_OBSERVE, SETUP_COMPLETE} @@ -173,6 +177,7 @@ class ModelCallRecoveryChildAuthorityFacts(BaseModel): AdminFacts = ( ModelProfileProvisioningFacts | ModelBudgetProvisioningFacts + | ModelUsageInspectionFacts | ModelCallRecoveryChildAuthorityFacts ) @@ -227,6 +232,7 @@ def exact_operation(self) -> Self: expected = { PROFILE_PROVISION: ModelProfileProvisioningFacts, BUDGET_CONFIGURE: ModelBudgetProvisioningFacts, + USAGE_READ: ModelUsageInspectionFacts, RECOVERY_CHILD_AUTHORIZE: ModelCallRecoveryChildAuthorityFacts, }.get(self.action_id) if expected is None or type(self.facts) is not expected: @@ -427,6 +433,8 @@ def _admin_identity(facts: AdminFacts, actor_profile_id: UUID) -> tuple[UUID, UU return facts.spec.project_id, facts.operation_id, facts.actor_profile_id if type(facts) is ModelCallRecoveryChildAuthorityFacts: return facts.project_id, facts.parent_attempt_id, actor_profile_id + if type(facts) is ModelUsageInspectionFacts: + return facts.project_id, facts.effect_id, facts.actor_profile_id raise ValueError("invalid project model administration facts") @@ -459,6 +467,7 @@ def _admin_identity(facts: AdminFacts, actor_profile_id: UUID) -> tuple[UUID, UU "SETUP_MODEL_USAGE_ACTIONS", "SETUP_OBSERVE", "SETUP_PREPARE", + "USAGE_READ", "SetupModelCompleteAuthorityFacts", "SetupModelDispatchAuthorityFacts", "SetupModelObserveAuthorityFacts", diff --git a/backend/app/modules/authorization/model_usage_authorization.py b/backend/app/modules/authorization/model_usage_authorization.py index 2d7aebf37..747204ae5 100644 --- a/backend/app/modules/authorization/model_usage_authorization.py +++ b/backend/app/modules/authorization/model_usage_authorization.py @@ -14,6 +14,7 @@ OBSERVE, PREPARE, PROFILE_PROVISION, + USAGE_READ, RECOVER, RECOVERY_CHILD_AUTHORIZE, SETUP_COMPLETE, @@ -60,6 +61,7 @@ ModelCallUnavailable, ModelPaidSlotKey, ModelProfileProvisioningFacts, + ModelUsageInspectionFacts, PreparedModelCallAttempt, PreparedSetupModelUsageEffect, SetupModelEffectAuthorityFacts, @@ -118,6 +120,22 @@ async def authorize_budget(self, facts: ModelBudgetProvisioningFacts) -> ModelAc model_action_digest("budget", facts), ) + async def authorize_usage_read(self, facts: ModelUsageInspectionFacts) -> ModelActionEvidence: + """Require a current system Operator for one exact redacted effect read.""" + facts = ModelUsageInspectionFacts.model_validate(facts) + self._require_human(facts.actor_profile_id) + resource = project_model_admin_resource( + USAGE_READ, + facts, + actor_profile_id=facts.actor_profile_id, + ) + return await self._authorize( + USAGE_READ, + resource, + facts.request_id, + model_action_digest("usage_read", facts), + ) + async def authorize_prepare( self, execution: ModelCallExecutionFacts, diff --git a/backend/app/modules/checkers/router.py b/backend/app/modules/checkers/router.py index 1bc1a56b6..a3c9e595d 100644 --- a/backend/app/modules/checkers/router.py +++ b/backend/app/modules/checkers/router.py @@ -18,17 +18,22 @@ ) from app.core.api_controls import ApiErrorResponse from app.api.deps.checker_recovery import ( - CheckerRetryCommands, CheckerRetryReads, - get_checker_retry_commands, get_checker_retry_reads, + CheckerRetryCommands, + CheckerRetryReads, + get_checker_retry_commands, + get_checker_retry_reads, require_checker_retry_human, - PostSubmitDeliveryCommands, PostSubmitDeliveryReads, - get_post_submit_delivery_commands, get_post_submit_delivery_reads, + PostSubmitDeliveryCommands, + PostSubmitDeliveryReads, + get_post_submit_delivery_commands, + get_post_submit_delivery_reads, require_post_submit_delivery_human, ) from app.api.deps.checker_models import ( MODEL_ADMIN_IDEMPOTENCY_PARAMETER, CheckerModelAdminCommands, get_checker_model_admin_commands, + get_checker_model_read_commands, get_checker_model_recovery_commands, require_model_admin_human, require_model_admin_key, @@ -50,7 +55,9 @@ ExternalCheckerRegistrySpec, ) from app.modules.checkers.api.execution import ( - CheckerRetryPublicResult, CheckerRetryRequest, CheckerRetrySourcePage, + CheckerRetryPublicResult, + CheckerRetryRequest, + CheckerRetrySourcePage, ) from app.modules.checkers.api.delivery_reconciliation import ( PostSubmitDeliveryPage, @@ -59,6 +66,7 @@ ) from app.modules.model_usage.api import ( ModelRecoveryChildRequest, + ModelUsageEffectInspection, PreparedModelCallAttempt, ProjectModelBudgetConfiguration, ProjectModelBudgetConfigurationSpec, @@ -88,9 +96,7 @@ async def provision_project_model_profile( project_id: UUID, payload: ProjectModelProfileSpec, - commands: Annotated[ - CheckerModelAdminCommands, Depends(get_checker_model_admin_commands) - ], + commands: Annotated[CheckerModelAdminCommands, Depends(get_checker_model_admin_commands)], ) -> ProjectModelProfile: """Publish or exactly replay one immutable approved project model profile.""" return await commands.provision_profile(project_id, payload) @@ -114,14 +120,31 @@ async def provision_project_model_profile( async def configure_project_model_budget( project_id: UUID, payload: ProjectModelBudgetConfigurationSpec, - commands: Annotated[ - CheckerModelAdminCommands, Depends(get_checker_model_admin_commands) - ], + commands: Annotated[CheckerModelAdminCommands, Depends(get_checker_model_admin_commands)], ) -> ProjectModelBudgetConfiguration: """Create or replay one immutable lifetime project model budget version.""" return await commands.configure_budget(project_id, payload) +@router.get( + "/operations/projects/{project_id}/model-usage/effects/{effect_id}", + response_model=ModelUsageEffectInspection, + dependencies=[ + Depends(enforce_authorization_read_rate_limit), + Depends(enforce_human_authorization_read), + ], + responses={code: {"model": ApiErrorResponse} for code in (404, 422, 503)}, + openapi_extra={"x-workstream-action-id": "project.model_usage.read"}, +) +async def read_project_model_usage_effect( + project_id: UUID, + effect_id: UUID, + commands: Annotated[CheckerModelAdminCommands, Depends(get_checker_model_read_commands)], +) -> ModelUsageEffectInspection: + """Read redacted accounting and evidence identities for one exact paid effect.""" + return await commands.inspect_effect(project_id, effect_id) + + @router.post( "/operations/projects/{project_id}/model-call-attempts/{parent_attempt_id}/recovery-children", response_model=PreparedModelCallAttempt, @@ -139,9 +162,7 @@ async def prepare_project_model_recovery_child( project_id: UUID, parent_attempt_id: UUID, payload: ModelRecoveryChildRequest, - commands: Annotated[ - CheckerModelAdminCommands, Depends(get_checker_model_recovery_commands) - ], + commands: Annotated[CheckerModelAdminCommands, Depends(get_checker_model_recovery_commands)], ) -> PreparedModelCallAttempt: """Create or replay a child only for one exact known terminal paid effect.""" return await commands.prepare_recovery_child(project_id, parent_attempt_id, payload) @@ -161,9 +182,7 @@ async def prepare_project_model_recovery_child( async def register_external_checker( payload: ExternalCheckerRegistrySpec, operation_id: Annotated[UUID, Depends(require_registry_key)], - operation: Annotated[ - CheckerRegistryOperation, Depends(get_registry_registration) - ], + operation: Annotated[CheckerRegistryOperation, Depends(get_registry_registration)], ) -> ExternalCheckerRegistryEntry: """Publish or exactly replay one immutable digest-pinned checker entry.""" response = await operation.service.register( @@ -193,6 +212,7 @@ async def read_external_checker( ) return await commit_registry_response(operation, response) + CHECKER_RETRY_KEY_PARAMETER = { "name": "Idempotency-Key", "in": "header", @@ -218,9 +238,7 @@ async def read_external_checker( ) async def list_uncertain_post_submit_deliveries( project_id: UUID, - reads: Annotated[ - PostSubmitDeliveryReads, Depends(get_post_submit_delivery_reads) - ], + reads: Annotated[PostSubmitDeliveryReads, Depends(get_post_submit_delivery_reads)], limit: Annotated[int, Query(ge=1, le=100)] = 50, cursor: Annotated[str | None, Query(max_length=512)] = None, ) -> PostSubmitDeliveryPage: @@ -245,9 +263,7 @@ async def reconcile_uncertain_post_submit_delivery( project_id: UUID, event_id: UUID, payload: PostSubmitDeliveryRequest, - commands: Annotated[ - PostSubmitDeliveryCommands, Depends(get_post_submit_delivery_commands) - ], + commands: Annotated[PostSubmitDeliveryCommands, Depends(get_post_submit_delivery_commands)], ) -> PostSubmitDeliveryResult: """Append or exactly replay the sole authorized reconciliation child.""" return await commands.reconcile(project_id, event_id, payload) @@ -295,7 +311,9 @@ async def retry_checker_infrastructure_failure( return CheckerRetryPublicResult.from_result(result) -@router.get("/submissions/{submission_id}/checker-runs", response_model=ContributorCheckerHistoryPage, +@router.get( + "/submissions/{submission_id}/checker-runs", + response_model=ContributorCheckerHistoryPage, dependencies=[Depends(enforce_human_authorization_read)], openapi_extra={"x-workstream-action-id": "submission.checker_run.list"}, ) @@ -307,11 +325,16 @@ async def read_contributor_checker_runs( ) -> ContributorCheckerHistoryPage: """Read retained checker runs under fresh contributor authority.""" return await history.read( - "checker_runs", submission_id, limit=limit, cursor=cursor, + "checker_runs", + submission_id, + limit=limit, + cursor=cursor, ) -@router.get("/projects/{project_id}/submissions/{submission_id}/checker-runs", response_model=ManagementCheckerHistoryPage, +@router.get( + "/projects/{project_id}/submissions/{submission_id}/checker-runs", + response_model=ManagementCheckerHistoryPage, dependencies=[Depends(enforce_human_authorization_read)], openapi_extra={"x-workstream-action-id": "project.submission.checker_run.list"}, ) @@ -324,11 +347,17 @@ async def read_management_checker_runs( ) -> ManagementCheckerHistoryPage: """Read retained checker runs under fresh management authority.""" return await history.read( - "checker_runs", submission_id, project_id=project_id, limit=limit, cursor=cursor, + "checker_runs", + submission_id, + project_id=project_id, + limit=limit, + cursor=cursor, ) -@router.get("/submissions/{submission_id}/checker-runs/{checker_run_id}", response_model=ContributorCheckerHistory, +@router.get( + "/submissions/{submission_id}/checker-runs/{checker_run_id}", + response_model=ContributorCheckerHistory, dependencies=[Depends(enforce_human_authorization_read)], openapi_extra={"x-workstream-action-id": "checker_run.read"}, ) @@ -339,11 +368,15 @@ async def read_contributor_checker_run( ) -> ContributorCheckerHistory: """Read retained checker run under fresh contributor authority.""" return await history.read( - "checker_run", checker_run_id, submission_id=submission_id, + "checker_run", + checker_run_id, + submission_id=submission_id, ) -@router.get("/projects/{project_id}/submissions/{submission_id}/checker-runs/{checker_run_id}", response_model=ManagementCheckerHistory, +@router.get( + "/projects/{project_id}/submissions/{submission_id}/checker-runs/{checker_run_id}", + response_model=ManagementCheckerHistory, dependencies=[Depends(enforce_human_authorization_read)], openapi_extra={"x-workstream-action-id": "project.checker_run.read"}, ) @@ -355,5 +388,8 @@ async def read_management_checker_run( ) -> ManagementCheckerHistory: """Read retained checker run under fresh management authority.""" return await history.read( - "checker_run", checker_run_id, submission_id=submission_id, project_id=project_id, + "checker_run", + checker_run_id, + submission_id=submission_id, + project_id=project_id, ) diff --git a/backend/app/modules/model_usage/api.py b/backend/app/modules/model_usage/api.py index 188c0c795..211137d74 100644 --- a/backend/app/modules/model_usage/api.py +++ b/backend/app/modules/model_usage/api.py @@ -320,6 +320,19 @@ def canonical_record_id(self) -> Self: return self +class ModelUsageInspectionFacts(ModelCallValue): + """Exact Operator read target committed before model-ledger inspection.""" + + project_id: UUID + effect_id: UUID + actor_profile_id: UUID + request_id: UUID + + _decode_public_uuids = field_validator( + "project_id", "effect_id", "actor_profile_id", "request_id", mode="before" + )(_public_uuid) + + class ModelPaidSlotKey(ModelCallValue): """Paid-effect identity stable across checker leases and retry generations.""" @@ -528,9 +541,7 @@ class SetupModelOwnerProfile(ModelCallValue): class SetupModelUsageOwnerPort(Protocol): """Require the exact current PROJECTS compilation attempt before MODEL locks.""" - async def require_setup_current( - self, slot: SetupModelUsageSlotKey - ) -> SetupModelOwnerProfile: + async def require_setup_current(self, slot: SetupModelUsageSlotKey) -> SetupModelOwnerProfile: """Lock and validate the complete setup attempt ancestry.""" ... @@ -604,9 +615,7 @@ def select(self, selector: SetupModelOutputSelector) -> SetupModelOutputSlot: class SetupModelOutputReservationPort(Protocol): """Resolve current request or retained paid-effect response ART custody.""" - async def resolve( - self, selector: SetupModelOutputSelector - ) -> SetupModelOutputReservation: + async def resolve(self, selector: SetupModelOutputSelector) -> SetupModelOutputReservation: """Return exact bounds after the role-specific owner checks.""" ... @@ -925,6 +934,49 @@ class KnownNoEffectSetupModelUsageEffect(ModelCallValue): replayed: bool +class ModelUsageEffectInspection(ModelCallValue): + """Redacted exact-effect accounting and evidence identities for an Operator.""" + + project_id: UUID + effect_id: UUID + owner_kind: Literal["checker", "setup"] + owner: ModelPaidSlotKey | SetupModelUsageSlotKey + attempt_number: StrictInt = Field(ge=1) + state: Literal[ + "prepared", "dispatching", "uncertain", "completed", "known_failed", "known_no_effect" + ] + provider_response_known: bool + request_evidence: ModelCallEvidence | None + response_evidence: ModelCallEvidence | None + usage: ModelCallUsage | None + budget_configuration_id: UUID | None + budget_configuration_version: StrictInt | None = Field(default=None, ge=1) + budget_configuration_digest: Sha256 | None + reserved_nanodollars: Nanodollars | None + settled_nanodollars: Nanodollars | None + uncertainty_digest: Sha256 | None + prepared_at: AwareDatetime + dispatched_at: AwareDatetime | None + terminal_at: AwareDatetime | None + + @model_validator(mode="after") + def exact_owner(self) -> Self: + expected = "checker" if isinstance(self.owner, ModelPaidSlotKey) else "setup" + if self.owner_kind != expected or self.owner.project_id != self.project_id: + raise ValueError("model usage inspection owner mismatch") + budget = ( + self.budget_configuration_id, + self.budget_configuration_version, + self.budget_configuration_digest, + self.reserved_nanodollars, + ) + if any(value is not None for value in budget) and not all( + value is not None for value in budget + ): + raise ValueError("model usage inspection budget identity is incomplete") + return self + + def model_retry_terminal_digest( terminal: KnownFailedModelCall | KnownNoEffectProof, ) -> str: @@ -1023,6 +1075,14 @@ async def prepare_recovery_child( ... +class ModelUsageInspectionPort(Protocol): + """Read one exact redacted paid effect without mutation authority.""" + + async def inspect_effect(self, facts: ModelUsageInspectionFacts) -> ModelUsageEffectInspection: + """Return the authorized effect only when it belongs to the exact project.""" + ... + + class ModelCallCurrentExecutionPort(Protocol): """Require one current authorized checker execution without granting model effects.""" @@ -1062,6 +1122,10 @@ async def authorize_budget(self, facts: ModelBudgetProvisioningFacts) -> ModelAc """Bind budget publication authority to the exact project and configuration.""" ... + async def authorize_usage_read(self, facts: ModelUsageInspectionFacts) -> ModelActionEvidence: + """Bind an Operator read to one exact project and paid effect.""" + ... + async def authorize_prepare( self, execution: ModelCallExecutionFacts, @@ -1166,6 +1230,9 @@ def model_action_digest(action: str, facts: object) -> str: "ModelCallRecoveryAttachment", "ModelCallUnavailable", "ModelCallUsage", + "ModelUsageEffectInspection", + "ModelUsageInspectionFacts", + "ModelUsageInspectionPort", "ModelDispatchReservation", "ModelEvidenceCustodyPort", "ModelEvidenceReservation", diff --git a/backend/app/modules/model_usage/inspection.py b/backend/app/modules/model_usage/inspection.py new file mode 100644 index 000000000..a2cfbfcb0 --- /dev/null +++ b/backend/app/modules/model_usage/inspection.py @@ -0,0 +1,152 @@ +"""Authorized redacted reads for one exact durable model effect.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.model_usage.api import ( + ModelCallAuthorityPort, + ModelCallEvidence, + ModelCallUnavailable, + ModelCallUsage, + ModelPaidSlotKey, + ModelUsageEffectInspection, + ModelUsageInspectionFacts, + SetupModelUsageSlotKey, + model_action_digest, +) +from app.modules.model_usage.models import ( + CheckerModelUsageSlot, + ModelUsageEffect, + SetupModelUsageSlot, +) +from app.modules.model_usage.repository import require_model_call_transaction + + +def _uuid(value: str) -> UUID: + return UUID(value) + + +def _checker_key(row: CheckerModelUsageSlot) -> ModelPaidSlotKey: + return ModelPaidSlotKey( + project_id=_uuid(row.project_id), + assignment_id=_uuid(row.assignment_id), + submission_id=_uuid(row.submission_id), + submission_version=row.submission_version, + requirement_id=row.requirement_id, + registry_entry_digest=row.registry_entry_digest, + configuration_sha256=row.configuration_sha256, + profile_id=_uuid(row.profile_id), + profile_version=row.profile_version, + profile_digest=row.profile_digest, + slot_index=row.slot_index, + ) + + +def _setup_key(row: SetupModelUsageSlot) -> SetupModelUsageSlotKey: + return SetupModelUsageSlotKey( + compilation_attempt_id=_uuid(row.compilation_attempt_id), + project_id=_uuid(row.project_id), + guide_id=_uuid(row.guide_id), + source_snapshot_id=_uuid(row.source_snapshot_id), + setup_run_id=_uuid(row.setup_run_id), + setup_generation=row.setup_generation, + runtime_configuration_hash=row.runtime_configuration_hash, + profile_id=_uuid(row.profile_id), + profile_version=row.profile_version, + profile_digest=row.profile_digest, + phase=row.phase, + call_index=row.call_index, + ) + + +def _optional_evidence(row: ModelUsageEffect, prefix: str) -> ModelCallEvidence | None: + values = { + "binding_id": getattr(row, f"{prefix}_binding_id"), + "content_id": getattr(row, f"{prefix}_content_id"), + "put_attempt_id": getattr(row, f"{prefix}_put_attempt_id"), + "verification_receipt_id": getattr(row, f"{prefix}_verification_receipt_id"), + "content_sha256": getattr(row, f"{prefix}_content_sha256"), + "byte_count": getattr(row, f"{prefix}_byte_count"), + } + if all(value is None for value in values.values()): + return None + if any(value is None for value in values.values()): + raise ModelCallUnavailable("model_usage_inspection_unavailable") + for field in ("binding_id", "content_id", "put_attempt_id", "verification_receipt_id"): + values[field] = _uuid(values[field]) + return ModelCallEvidence.model_validate(values) + + +def _optional_usage(row: ModelUsageEffect) -> ModelCallUsage | None: + values = { + "input_tokens": row.input_tokens, + "cached_input_tokens": row.cached_input_tokens, + "output_tokens": row.output_tokens, + "reasoning_tokens": row.reasoning_tokens, + } + if all(value is None for value in values.values()): + return None + if any(value is None for value in values.values()): + raise ModelCallUnavailable("model_usage_inspection_unavailable") + return ModelCallUsage.model_validate(values) + + +class ModelUsageInspectionService: + """Authorize before reading MODEL-USAGE and conceal absent or foreign effects.""" + + def __init__(self, session: AsyncSession, authority: ModelCallAuthorityPort) -> None: + self._session = session + self._authority = authority + + async def inspect_effect(self, facts: ModelUsageInspectionFacts) -> ModelUsageEffectInspection: + require_model_call_transaction(self._session) + facts = ModelUsageInspectionFacts.model_validate(facts) + evidence = await self._authority.authorize_usage_read(facts) + if evidence.facts_digest != model_action_digest("usage_read", facts): + raise ModelCallUnavailable("model_call_authority_unavailable") + + effect = await self._session.get(ModelUsageEffect, str(facts.effect_id)) + if effect is None: + raise ModelCallUnavailable("model_usage_inspection_unavailable") + if effect.checker_slot_id is not None: + slot = await self._session.get(CheckerModelUsageSlot, effect.checker_slot_id) + owner_kind = "checker" + owner = _checker_key(slot) if slot is not None else None + else: + slot = await self._session.get(SetupModelUsageSlot, effect.setup_slot_id) + owner_kind = "setup" + owner = _setup_key(slot) if slot is not None else None + if owner is None or owner.project_id != facts.project_id: + raise ModelCallUnavailable("model_usage_inspection_unavailable") + + return ModelUsageEffectInspection( + project_id=facts.project_id, + effect_id=facts.effect_id, + owner_kind=owner_kind, + owner=owner, + attempt_number=effect.attempt_number, + state=effect.state, + provider_response_known=effect.provider_response_id is not None, + request_evidence=_optional_evidence(effect, "request"), + response_evidence=_optional_evidence(effect, "response"), + usage=_optional_usage(effect), + budget_configuration_id=( + _uuid(effect.budget_configuration_id) + if effect.budget_configuration_id is not None + else None + ), + budget_configuration_version=effect.budget_configuration_version, + budget_configuration_digest=effect.budget_configuration_digest, + reserved_nanodollars=effect.reserved_nanodollars, + settled_nanodollars=effect.settled_nanodollars, + uncertainty_digest=effect.uncertainty_digest, + prepared_at=effect.prepared_at, + dispatched_at=effect.dispatched_at, + terminal_at=effect.terminal_at, + ) + + +__all__ = ("ModelUsageInspectionService",) diff --git a/backend/tests/authorization/catalogue_fixtures.py b/backend/tests/authorization/catalogue_fixtures.py index 0fd9fe372..065f966f5 100644 --- a/backend/tests/authorization/catalogue_fixtures.py +++ b/backend/tests/authorization/catalogue_fixtures.py @@ -135,7 +135,11 @@ "WS-XINT-002-06A", "active", ), - "artifact.submission.binding.create": ("artifact.binding.create", "WS-AUTH-001-ART-05", "active"), + "artifact.submission.binding.create": ( + "artifact.binding.create", + "WS-AUTH-001-ART-05", + "active", + ), "artifact.post_submit.checker_input.materialize": ( "artifact.checker_input.materialize", "WS-AUTH-001-ART-06A", @@ -248,7 +252,8 @@ ), } -historical_permissions = frozenset("""actor.profile.read_self actor.profile.update_self actor.profile.read_any +historical_permissions = frozenset( + """actor.profile.read_self actor.profile.update_self actor.profile.read_any actor.profile.suspend actor.profile.reactivate actor.profile.deactivate actor.identity_link.read actor.identity_link.revoke actor.identity_link.reactivate actor.service.provision admin_role.read admin_role.grant admin_role.revoke @@ -262,7 +267,8 @@ compensation.adapter_binding.manage compensation.award.read compensation.delivery.reconcile operations.status.read operations.timer.run operations.reconcile.run operations.outbox.retry operations.projection.rebuild - audit.read audit.export""".split()) + audit.read audit.export""".split() +) new_permissions = frozenset( """ @@ -288,18 +294,35 @@ expected = { "actor.project.list": ("actor.profile.read_self", "PILOT-READ-2"), - **dict.fromkeys(( - "actor.assignment.list", "actor.submission.list", "actor.work_summary.read", - "task.submission_bundle_preparation.list", "task.submission_bundle_preparation.read", - ), ("submission.read_own", "PILOT-READ-2")), - **dict.fromkeys(( - "project.guide.list", "project.guide.read", "project.guide.activation_readiness.read", - "project.guide.document.read", - ), ("project.guide.manage", "PILOT-READ-3")), - **dict.fromkeys(( - "project.submission.list", "project.assignment.list", "project.summary.read", - "project.task.import_batch.list", "project.submission_bundle_preparation.list", - ), ("project.task.manage", "PILOT-READ-3")), + **dict.fromkeys( + ( + "actor.assignment.list", + "actor.submission.list", + "actor.work_summary.read", + "task.submission_bundle_preparation.list", + "task.submission_bundle_preparation.read", + ), + ("submission.read_own", "PILOT-READ-2"), + ), + **dict.fromkeys( + ( + "project.guide.list", + "project.guide.read", + "project.guide.activation_readiness.read", + "project.guide.document.read", + ), + ("project.guide.manage", "PILOT-READ-3"), + ), + **dict.fromkeys( + ( + "project.submission.list", + "project.assignment.list", + "project.summary.read", + "project.task.import_batch.list", + "project.submission_bundle_preparation.list", + ), + ("project.task.manage", "PILOT-READ-3"), + ), "contribution.policy.list": ("compensation.policy.manage", "PILOT-READ-3"), "contribution.read_self": ("contribution.read_self", "PILOT-10"), "contribution.read_project": ("contribution.read_project", "PILOT-10"), @@ -334,6 +357,7 @@ ), "project.model_profile.provision": ("project.model_profile.manage", "PILOT-05"), "project.model_budget.configure": ("project.model_budget.manage", "PILOT-05"), + "project.model_usage.read": ("project.model_budget.manage", "PILOT-05"), "project.model_call.recovery_child.authorize": ( "project.model_call.recovery_child.authorize", "PILOT-05", @@ -348,11 +372,17 @@ "project.submission.read": ("project.task.manage", "task-checker-auth-cleanup"), "project.submission.checker_run.list": ("project.task.manage", "task-checker-auth-cleanup"), "project.checker_run.read": ("project.task.manage", "task-checker-auth-cleanup"), - "task.assignment.authority_reconcile": ("task.assignment.authority_reconcile", "WS-ARCH-001-03C1"), + "task.assignment.authority_reconcile": ( + "task.assignment.authority_reconcile", + "WS-ARCH-001-03C1", + ), "outbox.dispatch": ("outbox.dispatch", "WS-AUTH-001-OUTBOX-01"), "actor.profile.read_self": ("actor.profile.read_self", "WS-AUTH-001-07B"), "actor.profile.update_self": ("actor.profile.update_self", "WS-AUTH-001-07B"), - "operations.task.start_override": ("operations.task.start_override", "task-project-grant-authorization"), + "operations.task.start_override": ( + "operations.task.start_override", + "task-project-grant-authorization", + ), "task.claim": ("task.claim", "task-project-grant-authorization"), "task.start": ("task.claim", "task-project-grant-authorization"), "task.work_context.read": ("task.queue.read", "task-project-grant-authorization"), @@ -384,13 +414,16 @@ ), "operations.checker.retry": ("operations.checker.retry", "WS-AUTH-001-14"), "operations.checker_retry_source.list": ( - "operations.status.read", "WS-AUTH-001-14", + "operations.status.read", + "WS-AUTH-001-14", ), "operations.post_submit_delivery.list": ( - "operations.status.read", "WS-AUTH-001-14", + "operations.status.read", + "WS-AUTH-001-14", ), "operations.post_submit_delivery.reconcile": ( - "operations.reconcile.run", "WS-AUTH-001-14", + "operations.reconcile.run", + "WS-AUTH-001-14", ), "submission.create": ("submission.create", "WS-AUTH-001-14"), **{ @@ -465,10 +498,16 @@ "project.guide.create": ("project.guide.manage", "WS-AUTH-001-12D"), "project.guide.update": ("project.guide.manage", "WS-AUTH-001-12D"), "project.guide_source_snapshot.create": ("project.guide.manage", "WS-AUTH-001-12D"), - "project.guide_compilation.request_automatic": ("project.guide_compilation.execute", "WS-AUTH-001-12I"), + "project.guide_compilation.request_automatic": ( + "project.guide_compilation.execute", + "WS-AUTH-001-12I", + ), "project.guide_compilation.request": ("project.guide_compilation.request", "WS-AUTH-001-12I"), "project.guide_compilation.review_package.read": ("project.guide.manage", "WS-AUTH-001-12F4"), - "project.guide_compilation.correction.request": ("project.guide_compilation.request", "WS-AUTH-001-12F4"), + "project.guide_compilation.correction.request": ( + "project.guide_compilation.request", + "WS-AUTH-001-12F4", + ), "project.guide_compilation.execute": ("project.guide_compilation.execute", "WS-AUTH-001-12I"), "project.review_policy.update": ( "project.review_policy.manage", @@ -494,9 +533,9 @@ "project.effective_policy.manage", "WS-AUTH-001-12F2", ), - "project.submission_artifact_policy.derive": ( - "project.effective_policy.manage", - "WS-AUTH-001-12F3", + "project.submission_artifact_policy.derive": ( + "project.effective_policy.manage", + "WS-AUTH-001-12F3", ), "project.submission_artifact_policy.update": ( "project.effective_policy.manage", @@ -518,10 +557,23 @@ "project.effective_policy.manage", "WS-AUTH-001-12G", ), - "project.setup_run.update": ("project.guide.manage", "WS-AUTH-001-12B2"), "project.guide.activate": ("project.guide.manage", "WS-AUTH-001-12H"), # noqa: E501 - **dict.fromkeys((f"compensation.adapter_binding.{op}" for op in ("read", "create", "suspend", "resume")), ("compensation.adapter_binding.manage", "WS-ARCH-001-CP01A")), # noqa: E501 - **dict.fromkeys((f"contribution.policy.{op}" for op in ("read", "create_draft", "update_draft", "publish", "retire")), ("compensation.policy.manage", "WS-ARCH-001-CP01B")), # noqa: E501 - "contribution.compensation_unit.create": ("compensation.policy.manage", "project-points-unit-creation"), + "project.setup_run.update": ("project.guide.manage", "WS-AUTH-001-12B2"), + "project.guide.activate": ("project.guide.manage", "WS-AUTH-001-12H"), # noqa: E501 + **dict.fromkeys( + (f"compensation.adapter_binding.{op}" for op in ("read", "create", "suspend", "resume")), + ("compensation.adapter_binding.manage", "WS-ARCH-001-CP01A"), + ), # noqa: E501 + **dict.fromkeys( + ( + f"contribution.policy.{op}" + for op in ("read", "create_draft", "update_draft", "publish", "retire") + ), + ("compensation.policy.manage", "WS-ARCH-001-CP01B"), + ), # noqa: E501 + "contribution.compensation_unit.create": ( + "compensation.policy.manage", + "project-points-unit-creation", + ), } diff --git a/backend/tests/authorization/test_catalogue.py b/backend/tests/authorization/test_catalogue.py index 27fb72a4b..304cb6341 100644 --- a/backend/tests/authorization/test_catalogue.py +++ b/backend/tests/authorization/test_catalogue.py @@ -39,7 +39,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> assert {item.value for item in HISTORICAL_PERMISSION_IDS} == historical_permissions assert {item.value for item in NEW_PERMISSION_IDS} == new_permissions assert {item.value for item in PERMISSION_IDS} == historical_permissions | new_permissions - assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 190 + assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 191 assert set(ACTION_BY_ID) == ACTION_IDS assert {definition.owner for definition in ACTION_DEFINITIONS} == set(ActionOwner) assert { @@ -118,7 +118,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> } assert all(not owner.value.startswith("WS-REV-") for owner in ActionOwner) assert Counter(definition.availability for definition in ACTION_DEFINITIONS) == { - ActionAvailability.ACTIVE: 166, + ActionAvailability.ACTIVE: 167, ActionAvailability.PLANNED: 24, } assert ( diff --git a/backend/tests/checkers_model_calls/test_model_usage_inspection.py b/backend/tests/checkers_model_calls/test_model_usage_inspection.py new file mode 100644 index 000000000..76401c32c --- /dev/null +++ b/backend/tests/checkers_model_calls/test_model_usage_inspection.py @@ -0,0 +1,160 @@ +"""Redacted exact-effect inspection without provider or transcript disclosure.""" + +from datetime import UTC, datetime +from types import SimpleNamespace + +import pytest + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.model_usage.api import ( + ModelActionEvidence, + ModelCallUnavailable, + ModelUsageInspectionFacts, + model_action_digest, +) +from app.modules.model_usage.inspection import ModelUsageInspectionService +from app.modules.model_usage.models import CheckerModelUsageSlot, ModelUsageEffect + + +def sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +class _Transaction: + is_active = True + + +class _Session: + def __init__(self, effect, slot) -> None: + self.sync_session = self + self._effect = effect + self._slot = slot + + def get_transaction(self): + return _Transaction() + + def in_nested_transaction(self): + return False + + async def get(self, model, identity): + if model is ModelUsageEffect and identity == self._effect.id: + return self._effect + if model is CheckerModelUsageSlot and identity == self._slot.id: + return self._slot + return None + + +class _Authority: + def __init__(self) -> None: + self.facts = None + + async def authorize_usage_read(self, facts): + self.facts = facts + return ModelActionEvidence( + evidence_id=new_record_id(), + facts_digest=model_action_digest("usage_read", facts), + ) + + +def _rows(project_id): + slot = SimpleNamespace( + id=str(new_record_id()), + project_id=str(project_id), + assignment_id=str(new_record_id()), + submission_id=str(new_record_id()), + submission_version=1, + requirement_id="model_review", + registry_entry_digest=sha("registry"), + configuration_sha256=sha("configuration"), + profile_id=str(new_record_id()), + profile_version=2, + profile_digest=sha("profile"), + slot_index=1, + ) + effect = SimpleNamespace( + id=str(new_record_id()), + checker_slot_id=slot.id, + setup_slot_id=None, + attempt_number=1, + state="uncertain", + provider_response_id="provider-secret-coordinate", + request_binding_id=None, + request_content_id=None, + request_put_attempt_id=None, + request_verification_receipt_id=None, + request_content_sha256=None, + request_byte_count=None, + response_binding_id=None, + response_content_id=None, + response_put_attempt_id=None, + response_verification_receipt_id=None, + response_content_sha256=None, + response_byte_count=None, + input_tokens=None, + cached_input_tokens=None, + output_tokens=None, + reasoning_tokens=None, + budget_configuration_id=str(new_record_id()), + budget_configuration_version=3, + budget_configuration_digest=sha("budget"), + reserved_nanodollars=42, + settled_nanodollars=None, + uncertainty_digest=sha("uncertain"), + prepared_at=datetime.now(UTC), + dispatched_at=datetime.now(UTC), + terminal_at=None, + ) + return effect, slot + + +@pytest.mark.asyncio +async def test_inspection_authorizes_first_and_redacts_provider_coordinate() -> None: + project_id = new_record_id() + effect, slot = _rows(project_id) + authority = _Authority() + facts = ModelUsageInspectionFacts( + project_id=project_id, + effect_id=effect.id, + actor_profile_id=new_record_id(), + request_id=new_record_id(), + ) + + result = await ModelUsageInspectionService(_Session(effect, slot), authority).inspect_effect( + facts + ) + + assert authority.facts == facts + assert result.provider_response_known is True + assert result.model_dump(mode="json").get("provider_response_id") is None + assert result.owner.project_id == project_id + assert result.owner.assignment_id.hex == slot.assignment_id.replace("-", "") + assert result.owner.slot_index == 1 + assert result.reserved_nanodollars == 42 + + +@pytest.mark.asyncio +async def test_inspection_conceals_wrong_project_and_missing_effect() -> None: + project_id = new_record_id() + effect, slot = _rows(project_id) + authority = _Authority() + service = ModelUsageInspectionService(_Session(effect, slot), authority) + + with pytest.raises(ModelCallUnavailable, match="inspection_unavailable"): + await service.inspect_effect( + ModelUsageInspectionFacts( + project_id=new_record_id(), + effect_id=effect.id, + actor_profile_id=new_record_id(), + request_id=new_record_id(), + ) + ) + with pytest.raises(ModelCallUnavailable, match="inspection_unavailable"): + await service.inspect_effect( + ModelUsageInspectionFacts( + project_id=project_id, + effect_id=new_record_id(), + actor_profile_id=new_record_id(), + request_id=new_record_id(), + ) + ) diff --git a/backend/tests/checkers_model_calls/test_public_api.py b/backend/tests/checkers_model_calls/test_public_api.py index b8b544323..e548bd1ab 100644 --- a/backend/tests/checkers_model_calls/test_public_api.py +++ b/backend/tests/checkers_model_calls/test_public_api.py @@ -96,6 +96,13 @@ def test_openapi_exposes_only_exact_operator_model_mutations() -> None: assert len(keys) == 1 assert keys[0]["required"] is True + read = paths["/api/v1/operations/projects/{project_id}/model-usage/effects/{effect_id}"]["get"] + assert read["x-workstream-action-id"] == "project.model_usage.read" + assert "requestBody" not in read + assert read["responses"]["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/ModelUsageEffectInspection" + } + @pytest.mark.asyncio async def test_public_commands_bind_actor_key_project_and_exact_recovery_facts() -> None: diff --git a/docs/operations_roles_permissions.md b/docs/operations_roles_permissions.md index dba766f91..4697e75fb 100644 --- a/docs/operations_roles_permissions.md +++ b/docs/operations_roles_permissions.md @@ -167,6 +167,7 @@ Recovery is a separate, reasoned, audited path. | Checker terminal-failure retry (active public operation) | `operations.checker.retry` | System Operator under exact terminal-failure guards | | Immutable project model profile provisioning | `project.model_profile.manage` via `project.model_profile.provision` | System Operator for the exact project and registered image | | Lifetime project model budget configuration | `project.model_budget.manage` via `project.model_budget.configure` | System Operator; preserves all outstanding reservations and settled spend | +| Exact project model-effect inspection | `project.model_budget.manage` via `project.model_usage.read` | System Operator for one exact project/effect; redacted accounting and evidence identities only | | Model-call recovery child authorization | `project.model_call.recovery_child.authorize` | System Operator for an exact proven no-effect or fully settled failed parent; uncertain delivery is ineligible | | Review lease force release | `review.lease.force_release` | Operator under WS-REV-001 | | Checker revision-context repair (active public operation) | `project.task.manage` via `project.task.revision.repair` | Covered Project Manager | diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 049a02d96..2c62ab482 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -820,12 +820,14 @@ immutable specification or parent receipt; idempotent replay rechecks authority. |---|---|---| | `project.model_profile.provision` | `project.model_profile.manage` | Project, exact registered image and immutable provider/model/bounds/pricing profile | | `project.model_budget.configure` | `project.model_budget.manage` | Project and immutable budget configuration version; lifetime reservations and settled spend are never reset | +| `project.model_usage.read` | `project.model_budget.manage` | One exact paid effect in the project; redacted owner, budget, usage and ART evidence identities only | | `project.model_call.recovery_child.authorize` | `project.model_call.recovery_child.authorize` | Exact current execution and terminal parent receipt; only proven no-effect or fully settled known failure permits a child | An uncertain delivery is ineligible for a child. Model/provider budget failures are infrastructure outcomes, not contributor-fixable failures. Private evidence -is retained through ART's existing output writer and binding authorities, not a -new transcript read permission. See [model runtime operations](operations_checker_models.md) +is retained through ART's existing output writer and binding authorities. The +exact-effect read returns no transcript bytes, provider coordinate or credential +and adds no transcript permission. See [model runtime operations](operations_checker_models.md) for public routes, effect recovery and verification boundaries. Human task actions and existing permission mappings are enumerated in diff --git a/mcp_server/contracts/authorization_context_get.json b/mcp_server/contracts/authorization_context_get.json index 7dd28e714..112cbb429 100644 --- a/mcp_server/contracts/authorization_context_get.json +++ b/mcp_server/contracts/authorization_context_get.json @@ -1,5 +1,5 @@ { - "canonical_sha256": "f6bf204e2a075373d1ac737da6ce882057c2da09b89cdc41b5db8d05ac269a72", + "canonical_sha256": "0135c6dbb2ade3d6417ad0e32b8e33f9f7202cbe3ff2746ddf07efc6ee6c06f0", "components": { "schemas": { "ActionId": { @@ -71,6 +71,7 @@ "project.guide_compilation.model.complete", "project.model_profile.provision", "project.model_budget.configure", + "project.model_usage.read", "project.model_call.recovery_child.authorize", "outbox.dispatch", "actor.service.provision", From cfb76d519892021bb873bf91791da3a5dea428e0 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 21:12:10 +0100 Subject: [PATCH 024/115] fix(model-usage): enforce setup profile tool costs --- .../project_agents/setup_model_handler.py | 18 ++++++++- .../project_agents/setup_model_proxy.py | 12 ++++++ .../test_setup_model_handler.py | 6 ++- .../test_setup_model_proxy_transport.py | 38 +++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/backend/app/adapters/project_agents/setup_model_handler.py b/backend/app/adapters/project_agents/setup_model_handler.py index 46e4fddbc..a0bd5f8a5 100644 --- a/backend/app/adapters/project_agents/setup_model_handler.py +++ b/backend/app/adapters/project_agents/setup_model_handler.py @@ -406,7 +406,7 @@ async def _handle(self, request: SetupModelProxyRequest) -> SetupModelProxyReply response_evidence = await self._store(prepared, "model_response", response_data) async with self._sessions() as session, session.begin(): authority = await self._authorization(session, dispatch.internal_call_request_id) - completed = await self._calls(session, authority).complete( + await self._calls(session, authority).complete( dispatch.effect, provider_response_id=response.provider_response_id, provider_model_snapshot=response.provider_model, @@ -725,6 +725,7 @@ def _validate_openrouter_pricing( cached_ceiling = 0 output_ceiling = 0 for row in pricing_rows: + _require_zero_unsupported_fees(row) input_ceiling = max( input_ceiling, _nanodollars(row.get("prompt")), @@ -744,6 +745,21 @@ def _validate_openrouter_pricing( raise ModelCallUnavailable("model_profile_provider_unavailable") +def _require_zero_unsupported_fees(row: dict) -> None: + """Reject advertised charges the token-only ledger cannot reserve.""" + supported = { + "prompt", + "completion", + "input_cache_read", + "input_cache_write", + "overrides", + "min_prompt_tokens", + } + for key, value in row.items(): + if key not in supported and _nanodollars(value) != 0: + raise ModelCallUnavailable("model_profile_provider_unavailable") + + def _nanodollars(value: object) -> int: if type(value) is not str: raise ModelCallUnavailable("model_profile_provider_unavailable") diff --git a/backend/app/adapters/project_agents/setup_model_proxy.py b/backend/app/adapters/project_agents/setup_model_proxy.py index 46c38eec7..147e1537d 100644 --- a/backend/app/adapters/project_agents/setup_model_proxy.py +++ b/backend/app/adapters/project_agents/setup_model_proxy.py @@ -194,6 +194,8 @@ async def open_session( configuration.model, ): raise SetupModelProxyUnavailable("setup model profile is unavailable") + if not _profile_covers_tool_runtime(profile, configuration): + raise SetupModelProxyUnavailable("setup model profile is unavailable") if subject != SetupModelProxySubject( compilation_attempt_id=subject.compilation_attempt_id, project_id=subject.project_id, @@ -223,6 +225,16 @@ async def open_session( return open_session +def _profile_covers_tool_runtime(profile, configuration) -> bool: + """Require the selected profile to cover the fixed setup tool capability.""" + return ( + profile.spec.allowed_tools == ("read_document", "search_document") + and configuration.maximum_tool_calls <= profile.spec.maximum_tool_calls + and configuration.maximum_tool_output_characters * 4 + <= profile.spec.maximum_tool_output_bytes + ) + + def _decode_reply(payload: bytes) -> SetupModelProxyReply: value = json.loads( payload.decode("utf-8"), diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler.py b/backend/tests/checkers_model_calls/test_setup_model_handler.py index 84d4e9f14..b4e68485a 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_handler.py +++ b/backend/tests/checkers_model_calls/test_setup_model_handler.py @@ -202,7 +202,9 @@ async def count(self, **kwargs): ] -@pytest.mark.parametrize("mutation", ["context", "pricing", "alias", "parameters"]) +@pytest.mark.parametrize( + "mutation", ["context", "pricing", "request_fee", "alias", "parameters"] +) async def test_openrouter_metadata_drift_fails_before_dispatch(monkeypatch, mutation): import httpx @@ -212,6 +214,8 @@ async def test_openrouter_metadata_drift_fails_before_dispatch(monkeypatch, muta data["context_length"] = data["top_provider"]["context_length"] = 1_050_001 elif mutation == "pricing": data["pricing"]["overrides"][0]["completion"] = "0.000018001" + elif mutation == "request_fee": + data["pricing"]["request"] = "1" elif mutation == "alias": data["id"] = "openai/resolved-other-model" else: diff --git a/backend/tests/checkers_model_calls/test_setup_model_proxy_transport.py b/backend/tests/checkers_model_calls/test_setup_model_proxy_transport.py index 5222df35b..335f63917 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_proxy_transport.py +++ b/backend/tests/checkers_model_calls/test_setup_model_proxy_transport.py @@ -1,6 +1,7 @@ """Credential-free setup model frames share the private listener without widening it.""" import json +from types import SimpleNamespace import pytest @@ -8,6 +9,7 @@ from app.adapters.project_agents.setup_model_proxy import ( SetupModelProxySession, SetupModelProxyUnavailable, + _profile_covers_tool_runtime, ) from app.core.identifiers import new_record_id from app.interfaces.checker_model_proxy import ModelProxyReply @@ -152,3 +154,39 @@ def test_completed_reply_must_fit_after_inner_json_escaping(): failure_code=None, session_exhausted=True, ) + + +def test_setup_tool_runtime_must_fit_the_selected_profile_exactly(): + profile = SimpleNamespace( + spec=SimpleNamespace( + allowed_tools=("read_document", "search_document"), + maximum_tool_calls=3, + maximum_tool_output_bytes=400, + ) + ) + exact = SimpleNamespace( + maximum_tool_calls=3, + maximum_tool_output_characters=100, + ) + + assert _profile_covers_tool_runtime(profile, exact) is True + assert ( + _profile_covers_tool_runtime( + profile, + SimpleNamespace( + maximum_tool_calls=4, + maximum_tool_output_characters=100, + ), + ) + is False + ) + assert ( + _profile_covers_tool_runtime( + profile, + SimpleNamespace( + maximum_tool_calls=3, + maximum_tool_output_characters=101, + ), + ) + is False + ) From 9f5781eb8b891b8940ab0b1e74a334a77ab3deaa Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 21:20:03 +0100 Subject: [PATCH 025/115] fix(setup): normalize database UUID projection --- .../guide_compilation/orchestrator.py | 10 +- .../test_setup_model_handler_postgresql.py | 155 ++++++++++++++++-- 2 files changed, 143 insertions(+), 22 deletions(-) diff --git a/backend/app/modules/projects/guide_compilation/orchestrator.py b/backend/app/modules/projects/guide_compilation/orchestrator.py index e0fa78662..ec428840e 100644 --- a/backend/app/modules/projects/guide_compilation/orchestrator.py +++ b/backend/app/modules/projects/guide_compilation/orchestrator.py @@ -275,11 +275,11 @@ def _setup_model_subject( ): raise ProjectGuideCompilationExecutionError("runtime_unavailable") return SetupModelProxySubject( - compilation_attempt_id=state.preflight_facts.attempt_id, - project_id=state.identity.project_id, - guide_id=state.identity.guide_id, - source_snapshot_id=state.identity.source_snapshot_id, - setup_run_id=state.identity.setup_run_id, + compilation_attempt_id=UUID(str(state.preflight_facts.attempt_id)), + project_id=UUID(str(state.identity.project_id)), + guide_id=UUID(str(state.identity.guide_id)), + source_snapshot_id=UUID(str(state.identity.source_snapshot_id)), + setup_run_id=UUID(str(state.identity.setup_run_id)), setup_generation=state.identity.setup_generation, runtime_configuration_hash=configuration.sha256, profile_id=configuration.model_profile_id, diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py index 12c3fe0ad..09f8f80ad 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py +++ b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py @@ -7,8 +7,11 @@ from dataclasses import asdict, dataclass, replace import json import os +from types import SimpleNamespace from uuid import UUID, uuid4 +from asyncpg.pgproto.pgproto import UUID as AsyncpgUUID +import pytest from sqlalchemy import text from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine @@ -37,6 +40,7 @@ SetupProviderResponse, ) from app.adapters.projects import setup_model_usage_owner +from app.core.identifiers import new_record_id from app.interfaces.artifacts import ArtifactStoreNamespaceClaim from app.interfaces.setup_model_proxy import ( SetupModelProxyRequest, @@ -58,9 +62,14 @@ from app.modules.model_usage.api import ( ModelCallUnavailable, ModelCallUsage, + ModelUsageEffectInspection, ProjectModelProfile, ProjectModelProfileSpec, ) +from app.modules.projects.guide_compilation.orchestrator import _setup_model_subject +from app.modules.projects.guide_compilation.service import ( + load_compilation_execution_state, +) from tests.artifact_store_helpers import ( artifact_admission_limit_settings, artifact_preparation_limits, @@ -203,6 +212,73 @@ def _provider_request(profile: ProjectModelProfile) -> str: ) +def test_setup_subject_normalizes_database_uuid_subclasses_at_owner_projection() -> None: + """Keep the wire exact while accepting UUID values returned by asyncpg.""" + configuration_type = type(runtime_configuration()) + configuration = configuration_type.model_validate( + runtime_configuration().model_dump(mode="json") + | { + "model_profile_id": str(new_record_id()), + "model_profile_version": 1, + "model_profile_digest": "sha256:" + "1" * 64, + } + ) + database_ids = { + name: AsyncpgUUID(str(new_record_id())) + for name in ( + "attempt", + "project", + "guide", + "snapshot", + "setup", + ) + } + raw = dict( + compilation_attempt_id=database_ids["attempt"], + project_id=database_ids["project"], + guide_id=database_ids["guide"], + source_snapshot_id=database_ids["snapshot"], + setup_run_id=database_ids["setup"], + setup_generation=1, + runtime_configuration_hash=configuration.sha256, + profile_id=configuration.model_profile_id, + profile_version=configuration.model_profile_version, + profile_digest=configuration.model_profile_digest, + provider=configuration.model_provider, + api=configuration.model_api, + model=configuration.model, + ) + assert all(isinstance(value, UUID) and type(value) is not UUID for value in database_ids.values()) + with pytest.raises(ValueError, match="subject is invalid"): + SetupModelProxySubject(**raw) + + state = SimpleNamespace( + preflight_facts=SimpleNamespace(attempt_id=database_ids["attempt"]), + identity=SimpleNamespace( + project_id=database_ids["project"], + guide_id=database_ids["guide"], + source_snapshot_id=database_ids["snapshot"], + setup_run_id=database_ids["setup"], + setup_generation=1, + ), + ) + subject = _setup_model_subject(state, configuration) + + assert subject is not None + assert all( + type(getattr(subject, name)) is UUID + for name in ( + "compilation_attempt_id", + "project_id", + "guide_id", + "source_snapshot_id", + "setup_run_id", + "profile_id", + ) + ) + assert str(subject.compilation_attempt_id) == str(database_ids["attempt"]) + + @dataclass(slots=True) class _StorageCase: database_url: str @@ -383,21 +459,12 @@ async def _journey(storage: _StorageCase, access) -> _Journey: requested = await _fence_setup_attempt( storage.database_url, storage.values, configuration ) - subject = SetupModelProxySubject( - compilation_attempt_id=requested.attempt_id, - project_id=storage.values["project"], - guide_id=storage.values["guide"], - source_snapshot_id=storage.values["snapshot"], - setup_run_id=storage.values["setup_1"], - setup_generation=1, - runtime_configuration_hash=configuration.sha256, - profile_id=profile.profile_id, - profile_version=profile.spec.profile_version, - profile_digest=profile.profile_digest, - provider=profile.spec.provider, - api=profile.spec.api, - model=profile.spec.model_snapshot, - ) + async with storage.factory() as session: + state = await load_compilation_execution_state(session, requested.attempt_id) + assert state.runtime_configuration == configuration + subject = _setup_model_subject(state, configuration) + assert subject is not None + assert subject.profile_id == profile.profile_id request = SetupModelProxyRequest( subject, "draft", 0, _provider_request(profile) ) @@ -427,7 +494,12 @@ async def _state(journey: _Journey): await session.execute( text( "select effect.id,effect.state,effect.request_binding_id," - "effect.response_binding_id,effect.settled_nanodollars," + "effect.request_content_id,effect.request_put_attempt_id," + "effect.request_verification_receipt_id,effect.request_content_sha256," + "effect.request_byte_count,effect.response_binding_id," + "effect.response_content_id,effect.response_put_attempt_id," + "effect.response_verification_receipt_id,effect.response_content_sha256," + "effect.response_byte_count,effect.settled_nanodollars," "account.lifetime_reserved_nanodollars," "account.lifetime_settled_nanodollars " "from model_usage_effects effect " @@ -477,6 +549,53 @@ async def _assert_bound_private_art(journey: _Journey, state) -> None: assert leaked is False, table +def _assert_evidence_identity(observed, state, role: str) -> None: + assert observed is not None + for field in ("binding_id", "content_id", "put_attempt_id", "verification_receipt_id"): + assert str(getattr(observed, field)) == str(getattr(state, f"{role}_{field}")) + assert observed.content_sha256 == getattr(state, f"{role}_content_sha256") + assert observed.byte_count == getattr(state, f"{role}_byte_count") + + +async def _assert_operator_inspection(journey: _Journey, state, access) -> None: + endpoint = ( + f"/api/v1/operations/projects/{journey.request.subject.project_id}" + f"/model-usage/effects/{state.id}" + ) + response = await access.signed.client.get(endpoint, headers=access.target.headers) + assert response.status_code == 200, response.text + observed = ModelUsageEffectInspection.model_validate_json(response.text) + assert observed.project_id == journey.request.subject.project_id + assert observed.effect_id == UUID(str(state.id)) + assert observed.owner_kind == "setup" + assert observed.owner.compilation_attempt_id == journey.request.subject.compilation_attempt_id + assert observed.state == "completed" + assert observed.provider_response_known is True + assert observed.usage == ModelCallUsage( + input_tokens=1, + cached_input_tokens=0, + output_tokens=1, + reasoning_tokens=0, + ) + assert observed.settled_nanodollars == 23_000 + _assert_evidence_identity(observed.request_evidence, state, "request") + _assert_evidence_identity(observed.response_evidence, state, "response") + assert journey.response_marker not in response.text + assert "setup-response-1" not in response.text + assert "provider_response_id" not in response.json() + + wrong_project = await access.signed.client.get( + endpoint.replace(str(journey.request.subject.project_id), str(new_record_id())), + headers=access.target.headers, + ) + assert wrong_project.status_code == 404, wrong_project.text + missing = await access.signed.client.get( + endpoint.rsplit("/", 1)[0] + f"/{new_record_id()}", + headers=access.target.headers, + ) + assert missing.status_code == 404, missing.text + + async def test_setup_response_recovers_from_verified_minio_after_ledger_loss( tmp_path, isolated_database_env, @@ -503,8 +622,10 @@ async def test_setup_response_recovers_from_verified_minio_after_ledger_loss( assert completed.request_binding_id is not None assert completed.response_binding_id is not None assert completed.lifetime_reserved_nanodollars == 0 - assert completed.lifetime_settled_nanodollars == completed.settled_nanodollars + assert completed.settled_nanodollars == 23_000 + assert completed.lifetime_settled_nanodollars == 23_000 await _assert_bound_private_art(journey, completed) + await _assert_operator_inspection(journey, completed, admin_access) replay = await journey.handler().handle(journey.request) assert replay == recovered From 32cf50fbeec9083b7e98591c5e1c20f4bd85e31b Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 21:24:22 +0100 Subject: [PATCH 026/115] test(model-usage): conceal foreign setup effects --- .../test_setup_model_handler_postgresql.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py index 09f8f80ad..c78100ab8 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py +++ b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py @@ -24,6 +24,7 @@ admin_access as admin_access, signed_access as signed_access, ) +from tests.authorization.admin_access.support import create_project from app.adapters.artifacts import ( create_artifact_store_bootstrap, @@ -248,7 +249,9 @@ def test_setup_subject_normalizes_database_uuid_subclasses_at_owner_projection() api=configuration.model_api, model=configuration.model, ) - assert all(isinstance(value, UUID) and type(value) is not UUID for value in database_ids.values()) + assert all( + isinstance(value, UUID) and type(value) is not UUID for value in database_ids.values() + ) with pytest.raises(ValueError, match="subject is invalid"): SetupModelProxySubject(**raw) @@ -456,18 +459,14 @@ async def complete(self, *args, **kwargs): async def _journey(storage: _StorageCase, access) -> _Journey: profile = await _publish_profile(access, storage.values["project"]) configuration = _runtime(profile) - requested = await _fence_setup_attempt( - storage.database_url, storage.values, configuration - ) + requested = await _fence_setup_attempt(storage.database_url, storage.values, configuration) async with storage.factory() as session: state = await load_compilation_execution_state(session, requested.attempt_id) assert state.runtime_configuration == configuration subject = _setup_model_subject(state, configuration) assert subject is not None assert subject.profile_id == profile.profile_id - request = SetupModelProxyRequest( - subject, "draft", 0, _provider_request(profile) - ) + request = SetupModelProxyRequest(subject, "draft", 0, _provider_request(profile)) marker = f"private-setup-response-{uuid4()}" response = canonical_provider_json( { @@ -584,8 +583,9 @@ async def _assert_operator_inspection(journey: _Journey, state, access) -> None: assert "setup-response-1" not in response.text assert "provider_response_id" not in response.json() + foreign_project_id = await create_project("setup model inspection foreign project") wrong_project = await access.signed.client.get( - endpoint.replace(str(journey.request.subject.project_id), str(new_record_id())), + endpoint.replace(str(journey.request.subject.project_id), str(foreign_project_id)), headers=access.target.headers, ) assert wrong_project.status_code == 404, wrong_project.text From e87c07098be828c1a36ff64bfe3f79f353a9914e Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 21:28:37 +0100 Subject: [PATCH 027/115] test(model-usage): enforce setup tool caps at session open --- .../test_setup_model_proxy_transport.py | 116 ++++++++++++++---- 1 file changed, 90 insertions(+), 26 deletions(-) diff --git a/backend/tests/checkers_model_calls/test_setup_model_proxy_transport.py b/backend/tests/checkers_model_calls/test_setup_model_proxy_transport.py index 335f63917..b568d5510 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_proxy_transport.py +++ b/backend/tests/checkers_model_calls/test_setup_model_proxy_transport.py @@ -1,19 +1,23 @@ """Credential-free setup model frames share the private listener without widening it.""" +from contextlib import asynccontextmanager +from dataclasses import replace import json from types import SimpleNamespace import pytest from app.adapters.checkers.model_proxy_transport import ModelProxyTransport +import app.adapters.project_agents.setup_model_proxy as setup_model_proxy_adapter from app.adapters.project_agents.setup_model_proxy import ( SetupModelProxySession, SetupModelProxyUnavailable, - _profile_covers_tool_runtime, + setup_model_session_factory, ) from app.core.identifiers import new_record_id from app.interfaces.checker_model_proxy import ModelProxyReply from app.interfaces.setup_model_proxy import SetupModelProxyReply, SetupModelProxySubject +from tests.projects.guide_compilation.helpers import runtime_configuration def _sha(label: str) -> str: @@ -156,37 +160,97 @@ def test_completed_reply_must_fit_after_inner_json_escaping(): ) -def test_setup_tool_runtime_must_fit_the_selected_profile_exactly(): +def _tool_session_contract(): + profile_id = new_record_id() + profile_digest = _sha("profile") + configuration = runtime_configuration().model_copy( + update={ + "model_profile_id": profile_id, + "model_profile_version": 4, + "model_profile_digest": profile_digest, + "maximum_tool_calls": 3, + "maximum_tool_output_characters": 65_536, + } + ) profile = SimpleNamespace( + profile_id=profile_id, + profile_digest=profile_digest, spec=SimpleNamespace( + profile_version=4, + provider="openai", + api="responses", + model_snapshot="test-model", allowed_tools=("read_document", "search_document"), maximum_tool_calls=3, - maximum_tool_output_bytes=400, - ) + maximum_tool_output_bytes=65_536 * 4, + maximum_calls=3, + maximum_output_tokens=128, + settings=SimpleNamespace(reasoning_effort="medium", text_verbosity="low"), + deadline_ms=1_000, + ), ) - exact = SimpleNamespace( - maximum_tool_calls=3, - maximum_tool_output_characters=100, + subject = replace( + _subject(), + runtime_configuration_hash=configuration.sha256, + profile_id=profile_id, + profile_version=4, + profile_digest=profile_digest, + model="test-model", ) + return configuration, profile, subject - assert _profile_covers_tool_runtime(profile, exact) is True - assert ( - _profile_covers_tool_runtime( - profile, - SimpleNamespace( - maximum_tool_calls=4, - maximum_tool_output_characters=100, - ), - ) - is False + +class _Database: + @asynccontextmanager + async def begin(self): + yield + + +@asynccontextmanager +async def _sessions(): + yield _Database() + + +def _profile_reader(profile): + class Reader: + async def read_exact(self, selector, *, project_id): + del selector, project_id + return profile + + return lambda _database: Reader() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "runtime_limits", + ( + {"maximum_tool_calls": 4}, + {"maximum_tool_output_characters": 65_537}, + ), +) +async def test_setup_session_rejects_tool_limits_above_profile_before_yield( + monkeypatch, tmp_path, runtime_limits +): + configuration, profile, subject = _tool_session_contract() + monkeypatch.setattr(setup_model_proxy_adapter, "model_profile_reader", _profile_reader(profile)) + open_session = setup_model_session_factory( + sessions=_sessions, socket_path=tmp_path / "model.sock" ) - assert ( - _profile_covers_tool_runtime( - profile, - SimpleNamespace( - maximum_tool_calls=3, - maximum_tool_output_characters=101, - ), - ) - is False + + yielded = False + with pytest.raises(SetupModelProxyUnavailable, match="profile is unavailable"): + async with open_session(subject, configuration.model_copy(update=runtime_limits)): + yielded = True + assert yielded is False + + +@pytest.mark.asyncio +async def test_setup_session_accepts_exact_profile_tool_limits(monkeypatch, tmp_path): + configuration, profile, subject = _tool_session_contract() + monkeypatch.setattr(setup_model_proxy_adapter, "model_profile_reader", _profile_reader(profile)) + open_session = setup_model_session_factory( + sessions=_sessions, socket_path=tmp_path / "model.sock" ) + + async with open_session(subject, configuration) as session: + assert isinstance(session, SetupModelProxySession) From 78bebb4b44f564f3c977d96e60d18059ae079444 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 21:30:47 +0100 Subject: [PATCH 028/115] feat(model-usage): isolate hosted provider credentials --- deploy/hosted/README.md | 46 +++++---- deploy/hosted/compose.inference.yml | 12 +++ deploy/hosted/compose.yml | 40 ++++++++ deploy/hosted/preflight.py | 134 +++++++++++++++++++++++--- deploy/hosted/release.env.example | 7 +- docker/backend/Dockerfile.release | 3 +- docker/backend/release-entrypoint.sh | 12 ++- external_checkers/README.md | 12 ++- scripts/test_hosted_release_config.py | 103 +++++++++++++++++--- 9 files changed, 316 insertions(+), 53 deletions(-) diff --git a/deploy/hosted/README.md b/deploy/hosted/README.md index 3a75ca9b7..9f11da5c3 100644 --- a/deploy/hosted/README.md +++ b/deploy/hosted/README.md @@ -72,10 +72,13 @@ docker build --file docker/checker/Dockerfile.release \ Both Dockerfiles pin their base images. Each backend build requires one exact SDK environment and installs only its committed `agents` or `omnicore` resolution, without the development extra. The image records that choice in a root-owned -read-only marker; startup rejects a configured inference runtime that does not -match it before reading the provider credential. `manager_authored` is accepted -in either image with the exact `none/none/none` model tuple and reads no provider -credential. The backend image +read-only marker. Select the worker image that matches the configured setup SDK. +For every inference release, also publish the `agents` build separately as +`WORKSTREAM_MODEL_PROXY_IMAGE`: the dedicated trusted proxy uses its pinned +OpenAI 3.x input-count and Responses dependencies for both setup SDKs and POST +checkers. The proxy rejects any non-`agents` image before reading the provider +credential. `manager_authored` is accepted in either worker image with the exact +`none/none/none` model tuple and starts no proxy or credential surface. The backend image also installs the official Roles Anywhere helper 1.8.5 at its fixed path, with architecture-specific hashes checked during the build and image proof. The checker-service build compiles the committed Rust lock and copies the pinned @@ -112,11 +115,12 @@ create the fixed private directories with these exact identities: ```sh sudo install -d -m 0700 -o 10001 -g 10001 /var/lib/workstream/scratch sudo install -d -m 0700 -o 10001 -g 10001 /var/lib/workstream/checker-runtime +sudo install -d -m 0700 -o 10001 -g 10001 /var/lib/workstream/model-runtime sudo install -d -m 0750 -o 10002 -g 10001 /var/lib/workstream/guide-runtime stat -c '%g' /var/run/docker.sock ``` -The checker runtime directory must be on durable host storage, not `/run`, +The checker and model runtime directories must be on durable host storage, not `/run`, tmpfs or ephemeral container storage. Its private `.workstream-checker-owner` file identifies containers recoverable after a service or host restart. Only the checker service has a writable mount; API and worker socket mounts are read-only @@ -136,16 +140,19 @@ development authentication are absent. Create each secret selected by the configured runtime as a one-value regular file at the configured path. Make it owned by the runtime UID/GID with mode `0400`. The explicit role loader gives the API only the database, broker, rate-limit and pagination secrets; the worker -gets the database and broker. Inference runtimes additionally select and mount -one project-agent provider credential because the worker hosts setup jobs; -`manager_authored` must omit that selector, mount and secret definition. Beat +gets the database and broker. Inference runtimes additionally start one trusted +model-proxy process. Only that process gets the database, the selected provider +credential and the runtime ART identity. The setup worker receives a read-only +mount of `/var/lib/workstream/model-runtime` and the fixed socket selector; it +receives no provider credential or endpoint override. `manager_authored` omits +the proxy, selector, mount and secret definition. Beat gets the database and broker secrets because its startup guard verifies the current artifact-provider activation before scheduling jobs. An absent required selector or unknown role stops startup, and an unrelated secret is neither mounted nor loaded. The loader independently requires the running identity and mode `0400`, performs no shell evaluation and never prints a value. Do not put AWS static keys in these files. The provider selector is trusted release configuration. For `openrouter`, the -entrypoint loads the worker-only file as `OPENROUTER_API_KEY`; for `openai`, it +entrypoint loads the proxy-only file as `OPENROUTER_API_KEY`; for `openai`, it loads the same selector as `OPENAI_API_KEY`. It clears both native variables before selecting one and never exports a neutral key variable. The endpoint is fixed by the registered adapter and cannot be supplied in the release file. @@ -157,9 +164,10 @@ same release. The parser receives only its fixed socket selector. The example selects `openai_agents_sdk` through OpenRouter with `openai/gpt-5.6-terra`; changing a trusted selector requires another reviewed release and never changes retained attempt evidence. -To select `omnicore_agent`, publish and choose the `omnicore` image digest and -retain OpenRouter with `chat_completions`; ordinary model identity remains a -reviewed release choice. OpenRouter models with a colon routing suffix and +To select `omnicore_agent`, publish and choose the `omnicore` worker image digest, +retain the separate `agents` proxy digest, and retain OpenRouter with +`chat_completions`; ordinary model identity remains a reviewed release choice. +OpenRouter models with a colon routing suffix and `openrouter/*` router identities are rejected. Before either OpenRouter-backed runtime receives real guide material, the @@ -286,9 +294,10 @@ python3 deploy/hosted/compose.py --env-file /etc/workstream/release.env \ python3 deploy/hosted/compose.py --env-file /etc/workstream/release.env ps ``` -The API applies forward migrations before serving. The guide parser must pass -its protocol health check before the worker starts. Worker startup uses the -existing prefork lifecycle. Beat runs the existing recovery and cleanup +The API applies forward migrations before serving. The guide parser and trusted +model proxy must pass their protocol/socket health checks before an inference +worker starts. Worker startup uses the existing prefork lifecycle. Beat runs the +existing recovery and cleanup schedules. The checker health check requires its private socket and Docker's registered runsc runtime. It does not attest cache identity; full preflight observes the cache once, and the canonical runner verifies the exact image again @@ -317,9 +326,10 @@ outage does not change product results. Configuration alone is not that proof. The wrapper's `stop` operation stops Beat and API first so they cannot produce new work, gives the prefork worker its configured 7,320-second graceful window, -then stops the parser and checker controller. Keeping both socket services alive -while the worker drains lets cancellation and terminal persistence finish against -their original dependencies. Guide inference has a 7,260-second process backstop; +then drains the model proxy before stopping the parser and checker controller. +Keeping all socket services alive while the worker drains lets cancellation and +terminal persistence finish against their original dependencies. Guide inference +has a 7,260-second process backstop; the shared Redis transport uses a 7,380-second visibility timeout with prefetch disabled. The persisted guide-attempt deadline remains the ordinary, shorter execution bound. Celery task hard limits and durable database/outbox custody diff --git a/deploy/hosted/compose.inference.yml b/deploy/hosted/compose.inference.yml index ecf7d234f..08653028c 100644 --- a/deploy/hosted/compose.inference.yml +++ b/deploy/hosted/compose.inference.yml @@ -1,5 +1,17 @@ services: worker: + environment: + WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET: /var/lib/workstream/model-runtime/model.sock + volumes: + - type: bind + source: /var/lib/workstream/model-runtime + target: /var/lib/workstream/model-runtime + read_only: true + depends_on: + model-proxy: + condition: service_healthy + + model-proxy: environment: WORKSTREAM_PROJECT_AGENT_API_KEY_FILE: /run/secrets/project_agent_api_key secrets: diff --git a/deploy/hosted/compose.yml b/deploy/hosted/compose.yml index fb0fda9f7..575dd4136 100644 --- a/deploy/hosted/compose.yml +++ b/deploy/hosted/compose.yml @@ -304,6 +304,46 @@ services: retries: 6 start_period: 30s + model-proxy: + <<: *backend-runtime + profiles: [inference] + image: ${WORKSTREAM_MODEL_PROXY_IMAGE:?set the digest-qualified agents model-proxy image} + environment: + <<: *backend-environment + WORKSTREAM_RELEASE_ROLE: model-proxy + WORKSTREAM_DATABASE_URL_FILE: /run/secrets/database_url + WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET: /var/lib/workstream/model-runtime/model.sock + secrets: + - source: database_url + target: database_url + uid: "${WORKSTREAM_RUNTIME_UID}" + gid: "${WORKSTREAM_RUNTIME_GID}" + mode: 0400 + command: [python, -m, app.workers.checker_model_proxy] + volumes: + - type: bind + source: /var/lib/workstream/scratch + target: /var/lib/workstream/scratch + - type: bind + source: /var/lib/workstream/model-runtime + target: /var/lib/workstream/model-runtime + - type: bind + source: ${WORKSTREAM_ARTIFACT_S3_ROLES_ANYWHERE_RUNTIME_CERTIFICATE_HOST_FILE:?set the private runtime certificate path} + target: /run/workstream-roles-anywhere/certificate.pem + read_only: true + - type: bind + source: ${WORKSTREAM_ARTIFACT_S3_ROLES_ANYWHERE_RUNTIME_PRIVATE_KEY_HOST_FILE:?set the private runtime key path} + target: /run/workstream-roles-anywhere/private-key.pem + read_only: true + restart: on-failure:5 + stop_grace_period: 7320s + healthcheck: + test: [CMD-SHELL, "test -S /var/lib/workstream/model-runtime/model.sock"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s + beat: <<: *backend-runtime environment: diff --git a/deploy/hosted/preflight.py b/deploy/hosted/preflight.py index d428e6820..c87199feb 100644 --- a/deploy/hosted/preflight.py +++ b/deploy/hosted/preflight.py @@ -25,6 +25,8 @@ SOCKET_PATH = SOCKET_ROOT / "checker.sock" GUIDE_SOCKET_ROOT = Path("/var/lib/workstream/guide-runtime") GUIDE_SOCKET_PATH = GUIDE_SOCKET_ROOT / "guide.sock" +MODEL_SOCKET_ROOT = Path("/var/lib/workstream/model-runtime") +MODEL_SOCKET_PATH = MODEL_SOCKET_ROOT / "model.sock" GUIDE_PARSER_UID = 10002 GUIDE_PARSER_GID = 10001 DOCKER_SOCKET = Path("/var/run/docker.sock") @@ -80,6 +82,7 @@ "WORKSTREAM_DATABASE_URL_FILE", "WORKSTREAM_CELERY_BROKER_URL_FILE", ), + "model-proxy": ("WORKSTREAM_DATABASE_URL_FILE",), } FORBIDDEN_RELEASE_KEYS = ( "AWS_ACCESS_KEY_ID", @@ -192,6 +195,7 @@ REQUIRED_RELEASE_KEYS = ( "COMPOSE_PROJECT_NAME", "WORKSTREAM_BACKEND_IMAGE", + "WORKSTREAM_MODEL_PROXY_IMAGE", "WORKSTREAM_CHECKER_SERVICE_IMAGE", "WORKSTREAM_RUNTIME_UID", "WORKSTREAM_RUNTIME_GID", @@ -292,7 +296,11 @@ def validate_environment(values: dict[str, str]) -> None: ) for key in REQUIRED_RELEASE_KEYS: _required(values, key) - for key in ("WORKSTREAM_BACKEND_IMAGE", "WORKSTREAM_CHECKER_SERVICE_IMAGE"): + for key in ( + "WORKSTREAM_BACKEND_IMAGE", + "WORKSTREAM_MODEL_PROXY_IMAGE", + "WORKSTREAM_CHECKER_SERVICE_IMAGE", + ): if not IMAGE_REFERENCE.fullmatch(_required(values, key)): raise PreflightError(f"release image must use repository@sha256: {key}") for key in ( @@ -486,7 +494,7 @@ def _secret_file_keys(values: dict[str, str]) -> tuple[str, ...]: def _role_secret_file_keys(role: str, values: dict[str, str]) -> tuple[str, ...]: keys = ROLE_SECRET_FILE_KEYS[role] if ( - role == "worker" + role == "model-proxy" and values["WORKSTREAM_PROJECT_AGENT_RUNTIME"] != "manager_authored" ): return (*keys, PROJECT_AGENT_SECRET_FILE_KEY) @@ -499,6 +507,11 @@ def validate_private_host_inputs(values: dict[str, str]) -> None: gid = _positive_id(values, "WORKSTREAM_RUNTIME_GID") _private_metadata(SCRATCH_ROOT, kind="directory", uid=uid, gid=gid, mode=0o700) _private_metadata(SOCKET_ROOT, kind="directory", uid=uid, gid=gid, mode=0o700) + inference = values["WORKSTREAM_PROJECT_AGENT_RUNTIME"] != "manager_authored" + if inference: + _private_metadata( + MODEL_SOCKET_ROOT, kind="directory", uid=uid, gid=gid, mode=0o700 + ) _private_metadata( GUIDE_SOCKET_ROOT, kind="directory", @@ -521,6 +534,22 @@ def validate_private_host_inputs(values: dict[str, str]) -> None: or guide_socket.st_nlink != 1 ): raise PreflightError("guide parser socket has unsafe retained state") + if inference: + try: + model_socket = MODEL_SOCKET_PATH.lstat() + except FileNotFoundError: + model_socket = None + except OSError as exc: + raise PreflightError("model proxy socket identity is unavailable") from exc + if model_socket is not None and ( + stat.S_ISLNK(model_socket.st_mode) + or not stat.S_ISSOCK(model_socket.st_mode) + or model_socket.st_uid != uid + or model_socket.st_gid != gid + or stat.S_IMODE(model_socket.st_mode) != 0o600 + or model_socket.st_nlink != 1 + ): + raise PreflightError("model proxy socket has unsafe retained state") private_paths = [ Path(values["WORKSTREAM_CHECKER_CONFIG_FILE"]), *(Path(values[key]) for key in _secret_file_keys(values)), @@ -529,11 +558,13 @@ def validate_private_host_inputs(values: dict[str, str]) -> None: for path in private_paths: _private_metadata(path, kind="file", uid=uid, gid=gid, mode=0o400) try: - shared_roots = ( + shared_roots = [ SCRATCH_ROOT.resolve(strict=True), SOCKET_ROOT.resolve(strict=True), GUIDE_SOCKET_ROOT.resolve(strict=True), - ) + ] + if inference: + shared_roots.append(MODEL_SOCKET_ROOT.resolve(strict=True)) resolved_paths = [path.resolve(strict=True) for path in private_paths] except (OSError, RuntimeError) as exc: raise PreflightError("private host input identity is unavailable") from exc @@ -591,6 +622,7 @@ def _compose_file_arguments(values: dict[str, str]) -> list[str]: arguments = ["--file", str(COMPOSE_PATH)] if values["WORKSTREAM_PROJECT_AGENT_RUNTIME"] != "manager_authored": arguments.extend(("--file", str(INFERENCE_COMPOSE_PATH))) + arguments.extend(("--profile", "inference")) return arguments @@ -647,11 +679,13 @@ def run_compose( def run_shutdown(environment_path: Path, values: dict[str, str]) -> int: """Stop producers, drain the worker, then stop its isolated dependencies.""" - phases = ( + phases = [ ["stop", "beat", "api"], ["stop", "--timeout", "7320", "worker"], - ["stop", "guide-document-parser", "checker-service"], - ) + ] + if values["WORKSTREAM_PROJECT_AGENT_RUNTIME"] != "manager_authored": + phases.append(["stop", "--timeout", "7320", "model-proxy"]) + phases.append(["stop", "guide-document-parser", "checker-service"]) for arguments in phases: try: completed = subprocess.run( @@ -790,14 +824,18 @@ def _validate_exact_bind_mounts( def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: """Check the rendered release process and privilege topology.""" services = rendered.get("services") - if not isinstance(services, dict) or set(services) != { + expected_services = { "api", "worker", "beat", "guide-document-parser", "checker-service", *PROOF_SERVICES, - }: + } + inference = values["WORKSTREAM_PROJECT_AGENT_RUNTIME"] != "manager_authored" + if inference: + expected_services.add("model-proxy") + if not isinstance(services, dict) or set(services) != expected_services: raise PreflightError( "release Compose services differ from the bounded topology" ) @@ -823,9 +861,7 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: raise PreflightError(f"release service allows new privileges: {name}") if name != "guide-document-parser": if service.get("cap_add"): - raise PreflightError( - f"release service adds Linux capabilities: {name}" - ) + raise PreflightError(f"release service adds Linux capabilities: {name}") user = service.get("user") if not isinstance(user, str) or not re.fullmatch( r"[1-9][0-9]*:[1-9][0-9]*", user @@ -836,7 +872,16 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: runtime_identity = ( f"{values['WORKSTREAM_RUNTIME_UID']}:{values['WORKSTREAM_RUNTIME_GID']}" ) - for name in {"api", "worker", "beat", "checker-service", *PROOF_SERVICES}: + backend_runtime_services = { + "api", + "worker", + "beat", + "checker-service", + *PROOF_SERVICES, + } + if inference: + backend_runtime_services.add("model-proxy") + for name in backend_runtime_services: if services[name]["user"] != runtime_identity: raise PreflightError( f"release service has the wrong runtime identity: {name}" @@ -854,12 +899,18 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: ) if any(services[name]["image"] != backend_image for name in PROOF_SERVICES): raise PreflightError("AWS proof jobs must use the exact backend image") + if ( + inference + and services["model-proxy"]["image"] != values["WORKSTREAM_MODEL_PROXY_IMAGE"] + ): + raise PreflightError("model proxy must use the exact agents backend image") for name in ( "api", "worker", "beat", "guide-document-parser", *PROOF_SERVICES, + *({"model-proxy"} if inference else set()), ): if services[name].get("entrypoint") is not None: raise PreflightError(f"backend image entrypoint is overridden: {name}") @@ -894,6 +945,11 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: "api": shared_backend_mounts | runtime_identity_mounts, "worker": shared_backend_mounts | {str(GUIDE_SOCKET_ROOT): (str(GUIDE_SOCKET_ROOT), True)} + | ( + {str(MODEL_SOCKET_ROOT): (str(MODEL_SOCKET_ROOT), True)} + if inference + else {} + ) | runtime_identity_mounts, "beat": {}, "guide-document-parser": { @@ -930,6 +986,12 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: }, "aws-proof-coordinate": {}, } + if inference: + expected_mounts["model-proxy"] = { + str(SCRATCH_ROOT): (str(SCRATCH_ROOT), False), + str(MODEL_SOCKET_ROOT): (str(MODEL_SOCKET_ROOT), False), + **runtime_identity_mounts, + } for name, mounts in expected_mounts.items(): _validate_exact_bind_mounts(name, services[name], mounts) expected_secret_files = { @@ -1041,6 +1103,11 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: "guide-document-parser" ) != {"condition": "service_healthy", "required": True}: raise PreflightError("worker must wait for a healthy guide parser") + if inference and worker_dependencies.get("model-proxy") != { + "condition": "service_healthy", + "required": True, + }: + raise PreflightError("worker must wait for a healthy model proxy") worker = services["worker"] worker_command = worker.get("command") if ( @@ -1053,6 +1120,21 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: ) ): raise PreflightError("project guide worker delivery profile is invalid") + if inference: + proxy = services["model-proxy"] + if ( + proxy.get("profiles") != ["inference"] + or proxy.get("ports") + or proxy.get("command") + != ["python", "-m", "app.workers.checker_model_proxy"] + or proxy.get("stop_grace_period") not in {"7320s", "2h2m0s"} + or proxy.get("healthcheck", {}).get("test") + != [ + "CMD-SHELL", + "test -S /var/lib/workstream/model-runtime/model.sock", + ] + ): + raise PreflightError("model proxy delivery profile is invalid") for name in ("aws-proof-runtime", "aws-proof-readiness", "aws-proof-negative"): service = services[name] if service.get("profiles") != ["aws-proof"]: @@ -1099,7 +1181,8 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: ): raise PreflightError(f"{name} must receive only the database secret") environments: dict[str, dict[str, Any]] = {} - for role in ROLE_SECRET_FILE_KEYS: + active_roles = ("api", "worker", "beat") + (("model-proxy",) if inference else ()) + for role in active_roles: environment = services[role].get("environment") if not isinstance(environment, dict): raise PreflightError(f"{role} release environment is unavailable") @@ -1146,6 +1229,7 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: not in { "WORKSTREAM_RELEASE_ROLE", "WORKSTREAM_GUIDE_DOCUMENT_PARSER_SOCKET", + "WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET", } } ) @@ -1176,7 +1260,13 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: for key, value in exact.items(): if str(environment.get(key, "")).lower() != value: raise PreflightError(f"rendered release environment is unsafe: {key}") - for name in ("api", "worker", "beat", *PROOF_SERVICES): + for name in ( + "api", + "worker", + "beat", + *PROOF_SERVICES, + *(("model-proxy",) if inference else ()), + ): service_environment = services[name].get("environment") if not isinstance(service_environment, dict): raise PreflightError(f"{name} release environment is unavailable") @@ -1191,6 +1281,14 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: GUIDE_SOCKET_PATH ): raise PreflightError("worker lacks the fixed guide parser socket") + if inference and worker_environment.get( + "WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET" + ) != str(MODEL_SOCKET_PATH): + raise PreflightError("worker lacks the fixed model proxy socket") + if inference and environments["model-proxy"].get( + "WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET" + ) != str(MODEL_SOCKET_PATH): + raise PreflightError("model proxy lacks its fixed socket") for name in ("api", "beat", "checker-service", *PROOF_SERVICES): service_environment = services[name].get("environment") or {} if not isinstance(service_environment, dict): @@ -1201,6 +1299,12 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: raise PreflightError( f"{name} must not receive the project agent credential selector" ) + if "WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET" in service_environment: + raise PreflightError(f"{name} must not receive the model proxy socket") + if "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE" in worker_environment: + raise PreflightError( + "worker must not receive the project agent credential selector" + ) for name, service in services.items(): service_environment = service.get("environment") or {} if not isinstance(service_environment, dict): diff --git a/deploy/hosted/release.env.example b/deploy/hosted/release.env.example index d1e928fea..7cc1aa580 100644 --- a/deploy/hosted/release.env.example +++ b/deploy/hosted/release.env.example @@ -2,6 +2,7 @@ # an immutable repository@sha256 reference produced from the reviewed source. COMPOSE_PROJECT_NAME=workstream-release WORKSTREAM_BACKEND_IMAGE=registry.example/workstream/backend@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +WORKSTREAM_MODEL_PROXY_IMAGE=registry.example/workstream/backend-agents@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd WORKSTREAM_CHECKER_SERVICE_IMAGE=registry.example/workstream/checker-service@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb WORKSTREAM_RUNTIME_UID=10001 @@ -21,8 +22,10 @@ WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET_FILE=/etc/workstream/secrets/pagination WORKSTREAM_PROJECT_AGENT_API_KEY_FILE=/etc/workstream/secrets/project_agent_api_key # The runtime, provider, model and bounded effective profile are public trusted -# configuration. The worker entrypoint maps the one provider credential file to -# the selected provider's native variable; no endpoint or key value belongs here. +# configuration. Only the dedicated trusted model-proxy entrypoint maps the one +# provider credential file to the selected provider's native variable. The proxy +# image is always built with the pinned `agents` dependency set; workers may use +# either mutually exclusive project-agent SDK image and never receive the key. WORKSTREAM_PROJECT_AGENT_RUNTIME=openai_agents_sdk WORKSTREAM_PROJECT_AGENT_MODEL_PROVIDER=openrouter WORKSTREAM_PROJECT_AGENT_MODEL=openai/gpt-5.6-terra diff --git a/docker/backend/Dockerfile.release b/docker/backend/Dockerfile.release index 36c5a19ac..c9f5a9209 100644 --- a/docker/backend/Dockerfile.release +++ b/docker/backend/Dockerfile.release @@ -41,7 +41,8 @@ RUN test "${WORKSTREAM_RUNTIME_UID}" -ne 0 \ && useradd --uid "${WORKSTREAM_RUNTIME_UID}" --gid workstream \ --create-home --shell /usr/sbin/nologin workstream \ && install -d -m 0700 -o workstream -g workstream \ - /var/lib/workstream/scratch /var/lib/workstream/checker-runtime + /var/lib/workstream/scratch /var/lib/workstream/checker-runtime \ + /var/lib/workstream/model-runtime # The fixed guide parser owns this socket directory; workers only connect. RUN install -d -m 0750 -o 10002 -g 10001 /var/lib/workstream/guide-runtime diff --git a/docker/backend/release-entrypoint.sh b/docker/backend/release-entrypoint.sh index a9cc5fa28..00a9e5985 100644 --- a/docker/backend/release-entrypoint.sh +++ b/docker/backend/release-entrypoint.sh @@ -21,7 +21,12 @@ runtime="${WORKSTREAM_PROJECT_AGENT_RUNTIME:-}" provider="${WORKSTREAM_PROJECT_AGENT_MODEL_PROVIDER:-}" model_api="${WORKSTREAM_PROJECT_AGENT_MODEL_API:-}" model="${WORKSTREAM_PROJECT_AGENT_MODEL:-}" -if [ "$role" != "parser" ]; then +if [ "$role" = "model-proxy" ]; then + if [ "$sdk_environment" != "agents" ]; then + echo "model proxy requires the pinned agents SDK environment" >&2 + exit 1 + fi +elif [ "$role" != "parser" ]; then case "$runtime:$sdk_environment" in openai_agents_sdk:agents|omnicore_agent:omnicore) ;; @@ -96,8 +101,13 @@ case "$role" in worker) load_required_secret WORKSTREAM_DATABASE_URL "${WORKSTREAM_DATABASE_URL_FILE:-}" load_required_secret WORKSTREAM_CELERY_BROKER_URL "${WORKSTREAM_CELERY_BROKER_URL_FILE:-}" + ;; + model-proxy) + load_required_secret WORKSTREAM_DATABASE_URL "${WORKSTREAM_DATABASE_URL_FILE:-}" case "$runtime" in manager_authored) + echo "manager-authored releases do not run a model proxy" >&2 + exit 1 ;; *) case "$provider" in openrouter) diff --git a/external_checkers/README.md b/external_checkers/README.md index 3429179c1..e2f08118e 100644 --- a/external_checkers/README.md +++ b/external_checkers/README.md @@ -80,13 +80,15 @@ invocation's private grant and request digest. Setup and POST sessions share one project-lifetime metered account while retaining distinct typed setup-attempt and checker-invocation lineage. The checker container remains on `--network none`. -The dedicated singleton model-proxy worker is optional. Add `model-proxy` to -the existing backend and agent-loop Compose profiles to enable it. That profile +The dedicated singleton model-proxy worker is optional for provider-free +deployments. Add `model-proxy` to the existing backend and agent-loop Compose +profiles to enable model-backed setup or POST checks. That profile sets `WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET=/run/ws-model-proxy/model-proxy.sock` and -binds the listener; the ordinary API, task worker and beat processes do not own -or mount that socket. An empty value outside the model-proxy process leaves the -listener disabled. The Rust service configuration must set both +binds the listener. The setup task worker connects through a read-only mount of +that socket but never owns it or receives provider credentials; the ordinary API +and Beat processes do not mount it. An empty value outside the model-proxy and +authorized setup-worker processes leaves the listener disabled. The Rust service configuration must set both `model_proxy_socket` to that same mounted path and `model_relay_root` to a daemon-visible private relay directory. The local configuration generator's `--enable-model-proxy` switch emits that pair, using diff --git a/scripts/test_hosted_release_config.py b/scripts/test_hosted_release_config.py index d4f587d40..69152aada 100644 --- a/scripts/test_hosted_release_config.py +++ b/scripts/test_hosted_release_config.py @@ -137,6 +137,7 @@ def test_valid_configuration_and_rendered_topology(self) -> None: "api", "worker", "beat", + "model-proxy", "guide-document-parser", "checker-service", *preflight.PROOF_SERVICES, @@ -151,6 +152,7 @@ def test_valid_configuration_and_rendered_topology(self) -> None: "api", "worker", "beat", + "model-proxy", "guide-document-parser", "checker-service", ): @@ -175,7 +177,13 @@ def test_valid_configuration_and_rendered_topology(self) -> None: key: self.environment[key] for key in preflight.PROJECT_AGENT_CONFIGURATION_KEYS } - for name in ("api", "worker", "beat", *preflight.PROOF_SERVICES): + for name in ( + "api", + "worker", + "beat", + "model-proxy", + *preflight.PROOF_SERVICES, + ): self.assertEqual( { key: services[name]["environment"][key] @@ -190,14 +198,25 @@ def test_valid_configuration_and_rendered_topology(self) -> None: self.assertIn("--pool=prefork", worker_command) health_commands = { name: " ".join(services[name]["healthcheck"]["test"]) - for name in ("api", "worker", "beat", "checker-service") + for name in ( + "api", + "worker", + "beat", + "model-proxy", + "checker-service", + ) } self.assertIn("/api/v1/health", health_commands["api"]) self.assertIn("/tmp/celery-worker.pid", health_commands["worker"]) self.assertIn("/tmp/celerybeat.pid", health_commands["beat"]) + self.assertIn( + "/var/lib/workstream/model-runtime/model.sock", + health_commands["model-proxy"], + ) self.assertIn(".Runtimes.runsc", health_commands["checker-service"]) self.assertEqual(services["api"]["stop_grace_period"], "30s") self.assertIn(services["worker"]["stop_grace_period"], {"7320s", "2h2m0s"}) + self.assertIn(services["model-proxy"]["stop_grace_period"], {"7320s", "2h2m0s"}) self.assertEqual(services["beat"]["stop_grace_period"], "30s") self.assertEqual(services["guide-document-parser"]["stop_grace_period"], "30s") self.assertEqual(services["api"]["ports"][0]["host_ip"], "127.0.0.1") @@ -272,7 +291,7 @@ def test_valid_configuration_and_rendered_topology(self) -> None: str(preflight.GUIDE_SOCKET_ROOT), preflight._service_mounts(services[name]), ) - for name in ("api", "worker"): + for name in ("api", "worker", "model-proxy"): mounts = preflight._service_mounts(services[name]) self.assertEqual( mounts[str(preflight.ROLES_ANYWHERE_CERTIFICATE_TARGET)]["source"], @@ -283,6 +302,18 @@ def test_valid_configuration_and_rendered_topology(self) -> None: ) beat_mounts = preflight._service_mounts(services["beat"]) self.assertEqual(beat_mounts, {}) + model_socket_root = str(preflight.MODEL_SOCKET_ROOT) + self.assertTrue(worker_mounts[model_socket_root]["read_only"]) + self.assertFalse( + preflight._service_mounts(services["model-proxy"])[model_socket_root].get( + "read_only", False + ) + ) + for name in ("api", "beat", "checker-service", *preflight.PROOF_SERVICES): + self.assertNotIn( + model_socket_root, + preflight._service_mounts(services[name]), + ) checker_mounts = preflight._service_mounts(services["checker-service"]) self.assertNotIn( str(preflight.ROLES_ANYWHERE_CERTIFICATE_TARGET), checker_mounts @@ -605,16 +636,20 @@ def test_shutdown_stops_producers_then_worker_then_parser_and_checker(self) -> N self.assertEqual(commands[0][-3:], ["stop", "beat", "api"]) self.assertEqual(commands[1][-4:], ["stop", "--timeout", "7320", "worker"]) self.assertEqual( - commands[2][-3:], + commands[2][-4:], + ["stop", "--timeout", "7320", "model-proxy"], + ) + self.assertEqual( + commands[3][-3:], ["stop", "guide-document-parser", "checker-service"], ) def test_shutdown_failure_keeps_later_dependencies_running(self) -> None: - for failed_phase in (0, 1): + for failed_phase in (0, 1, 2): with self.subTest(failed_phase=failed_phase): results = [ subprocess.CompletedProcess([], 17 if index == failed_phase else 0) - for index in range(3) + for index in range(4) ] with patch.object( preflight.subprocess, "run", side_effect=results @@ -1207,9 +1242,11 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( ) -> None: scratch = self.root / "scratch" socket_root = self.root / "socket" + model_socket_root = self.root / "model-socket" guide_socket_root = self.root / "guide-socket" scratch.mkdir(mode=0o700) socket_root.mkdir(mode=0o700) + model_socket_root.mkdir(mode=0o700) guide_socket_root.mkdir(mode=0o750) host_uid = os.getuid() host_gid = os.getgid() @@ -1237,6 +1274,12 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( with ( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), + patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object( + preflight, + "MODEL_SOCKET_PATH", + model_socket_root / "model.sock", + ), patch.object(preflight, "GUIDE_SOCKET_ROOT", guide_socket_root), patch.object( preflight, @@ -1297,6 +1340,12 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( with ( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), + patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object( + preflight, + "MODEL_SOCKET_PATH", + model_socket_root / "model.sock", + ), patch.object(preflight, "GUIDE_SOCKET_ROOT", guide_socket_root), patch.object( preflight, @@ -1323,6 +1372,12 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( with ( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), + patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object( + preflight, + "MODEL_SOCKET_PATH", + model_socket_root / "model.sock", + ), patch.object(preflight, "GUIDE_SOCKET_ROOT", guide_socket_root), patch.object( preflight, @@ -1348,6 +1403,12 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( with ( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), + patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object( + preflight, + "MODEL_SOCKET_PATH", + model_socket_root / "model.sock", + ), patch.object(preflight, "GUIDE_SOCKET_ROOT", guide_socket_root), patch.object( preflight, @@ -1367,6 +1428,12 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( with ( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), + patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object( + preflight, + "MODEL_SOCKET_PATH", + model_socket_root / "model.sock", + ), patch.object(preflight, "GUIDE_SOCKET_ROOT", guide_socket_root), patch.object( preflight, @@ -1392,6 +1459,12 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( with ( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), + patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object( + preflight, + "MODEL_SOCKET_PATH", + model_socket_root / "model.sock", + ), patch.object(preflight, "GUIDE_SOCKET_ROOT", guide_socket_root), patch.object( preflight, @@ -1430,6 +1503,9 @@ class ReleaseSecretLoaderTests(unittest.TestCase): "worker": ( "WORKSTREAM_DATABASE_URL_FILE", "WORKSTREAM_CELERY_BROKER_URL_FILE", + ), + "model-proxy": ( + "WORKSTREAM_DATABASE_URL_FILE", "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE", ), "beat": ( @@ -1511,9 +1587,7 @@ def _run_loader( ) environment["EXPECT_PARSER_SELECTOR_SCRUB"] = "1" if role == "parser" else "0" expected = {self.FILE_TO_SECRET[key] for key in self.ROLE_FILES.get(role, ())} - if role == "worker" and runtime == "manager_authored": - expected.remove("OPENROUTER_API_KEY") - if role == "worker" and provider == "openai": + if role == "model-proxy" and provider == "openai": expected.remove("OPENROUTER_API_KEY") expected.add("OPENAI_API_KEY") environment["EXPECTED_SECRET_NAMES"] = ",".join(sorted(expected)) @@ -1603,9 +1677,9 @@ def test_loader_loads_only_role_secrets_and_rejects_missing_or_unknown_role( self.assertIn( "required secret file is unavailable", missing.stderr ) - openai = self._run_loader(root, provider="openai") + openai = self._run_loader(root, role="model-proxy", provider="openai") self.assertEqual(openai.returncode, 0, openai.stderr) - unsupported = self._run_loader(root, provider="unknown") + unsupported = self._run_loader(root, role="model-proxy", provider="unknown") self.assertNotEqual(unsupported.returncode, 0) self.assertIn("model provider", unsupported.stderr) unknown = self._run_loader(root, role="unknown") @@ -1622,6 +1696,13 @@ def test_loader_binds_runtime_to_sdk_marker_before_provider_secret(self) -> None sdk_environment="omnicore", ) self.assertEqual(omni.returncode, 0, omni.stderr) + omni_proxy = self._run_loader( + root, + role="model-proxy", + runtime="omnicore_agent", + sdk_environment="agents", + ) + self.assertEqual(omni_proxy.returncode, 0, omni_proxy.stderr) for sdk_environment in ("agents", "omnicore"): with self.subTest( runtime="manager_authored", sdk_environment=sdk_environment From 01c11178f5eed846058e66372d703d93e57ed7ea Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 21:48:58 +0100 Subject: [PATCH 029/115] test(model-usage): refresh inspection schema fingerprint --- backend/tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 357657c9e..d346d6816 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,7 +23,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "197e65fed2bcba5cc7888fe6278b6f32ea9ff817329859036713bbb9d41c8f59" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "5eab828a9afc22547f0b6e4c197ba8e0057290200878b890f2a0225249d0a3d7" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", From f7be6148d8c9558897d3a59e1f561bea24659421 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 21:52:10 +0100 Subject: [PATCH 030/115] fix(checkers): bind model-dependent execution profiles --- .../app/core/project_guide_instructions.py | 14 ++--- .../modules/checkers/api/external_policy.py | 5 +- .../tests/checkers/external/test_policy.py | 57 +++++++++++++++++++ 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/backend/app/core/project_guide_instructions.py b/backend/app/core/project_guide_instructions.py index 7c306a0da..407ca7eba 100644 --- a/backend/app/core/project_guide_instructions.py +++ b/backend/app/core/project_guide_instructions.py @@ -158,7 +158,7 @@ { "dependency_id": "reasoning_model", "kind": "model_service", - "provisioning": "runtime_gap", + "provisioning": "model_proxy", "purpose": "Apply the qualitative evidence rubric.", "unavailable_code": "implementation_unavailable", } @@ -317,13 +317,11 @@ capacity, deadline, provider failure and invalid output cannot consume revision rounds. DEPENDENCIES -Specifications grant no access. Current images receive verified package material, -manifest, approved configuration and normalized request metadata, not task/guide -prose, provider credentials or nested environments. A model-enabled POST image -receives only its invocation-local proxy; model_evaluation requires model_service, -model_proxy and implementation_unavailable plus a registered project image and its -exact approved profile. If either is missing, keep an unbound blocking implementation -suggestion. Declare unavailable needs explicitly; +Specs grant no access. Images receive verified material, manifest, configuration +and request metadata, not guide prose, provider +keys or nested environments. POST model_evaluation requires model_service/model_proxy, +implementation_unavailable, a registered image and exact profile; else stays +unbound. Declare unavailable needs explicitly; a supported binding cannot depend on runtime_gap. current_runner isolates the checker image only; it supplies no container engine, second sandbox or host socket. Follow the schema's exact dependency kind/provisioning/unavailable_code pairs. diff --git a/backend/app/modules/checkers/api/external_policy.py b/backend/app/modules/checkers/api/external_policy.py index beb57ead9..47b0bf859 100644 --- a/backend/app/modules/checkers/api/external_policy.py +++ b/backend/app/modules/checkers/api/external_policy.py @@ -304,8 +304,9 @@ async def _resolve( try: selector = configured_model_profile_selector(configuration, stage=phase) specification = requirement.check_specification - model_required = ( - specification is not None and specification.evaluation_method == "model_evaluation" + model_required = specification is not None and any( + dependency.kind == "model_service" + for dependency in specification.runtime_dependencies ) if model_required != (selector is not None): raise ModelProfileConfigurationError( diff --git a/backend/tests/checkers/external/test_policy.py b/backend/tests/checkers/external/test_policy.py index b0fce6977..27593903d 100644 --- a/backend/tests/checkers/external/test_policy.py +++ b/backend/tests/checkers/external/test_policy.py @@ -299,6 +299,63 @@ async def test_model_configuration_requires_exact_post_profile_scope(): } +@pytest.mark.asyncio +async def test_isolated_execution_model_dependency_also_requires_exact_profile(): + project_id = new_record_id() + pre = _entry("acme.safe_archive", "pre_submit") + post = _entry( + "acme.quality_review", + "post_submit", + _model_configuration_schema(), + ) + profile = _model_profile(project_id, post) + proposal = _model_proposal() + specification = proposal.requirements[1].check_specification + assert specification is not None + specification = specification.model_copy( + update={ + "evaluation_method": "isolated_execution", + "runtime_dependencies": ( + CheckRuntimeDependency( + dependency_id="isolated_runtime", + kind="isolated_execution", + provisioning="current_runner", + purpose="Execute the bounded authenticated trial.", + unavailable_code="implementation_unavailable", + ), + *specification.runtime_dependencies, + ), + } + ) + requirement = proposal.requirements[1].model_copy(update={"check_specification": specification}) + proposal = proposal.model_copy(update={"requirements": (proposal.requirements[0], requirement)}) + + with pytest.raises(ValueError, match="model evaluation profile"): + await compile_external_checker_policies( + proposal, + _Registry((pre, post)), + project_id=project_id, + profiles=_Profiles(profile), + ) + + binding = proposal.post_submit_bindings[0].model_copy( + update={ + "parameters": ( + CapabilityParameter(name="model_profile_id", value=str(profile.profile_id)), + CapabilityParameter(name="model_profile_version", value=1), + CapabilityParameter(name="model_profile_digest", value=profile.profile_digest), + ) + } + ) + _, policy = await compile_external_checker_policies( + proposal.model_copy(update={"post_submit_bindings": (binding,)}), + _Registry((pre, post)), + project_id=project_id, + profiles=_Profiles(profile), + ) + assert policy.entries[0].configuration["model_profile_id"] == str(profile.profile_id) + + @pytest.mark.asyncio async def test_model_configuration_rejects_partial_pre_and_substituted_profile(): project_id = new_record_id() From 5149c9116f3a80f7aacb3f359181f3d555c262c5 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 21:54:54 +0100 Subject: [PATCH 031/115] fix(model-usage): decouple proxy from setup runtime --- deploy/hosted/README.md | 32 +++--- deploy/hosted/compose.inference.yml | 18 +++- deploy/hosted/compose.yml | 2 +- deploy/hosted/preflight.py | 99 ++++++++++-------- deploy/hosted/release.env.example | 13 ++- docker/backend/release-entrypoint.sh | 23 +---- scripts/test_hosted_release_config.py | 141 +++++++++++++++++++++----- 7 files changed, 214 insertions(+), 114 deletions(-) diff --git a/deploy/hosted/README.md b/deploy/hosted/README.md index 9f11da5c3..be07c496f 100644 --- a/deploy/hosted/README.md +++ b/deploy/hosted/README.md @@ -73,12 +73,13 @@ Both Dockerfiles pin their base images. Each backend build requires one exact SD environment and installs only its committed `agents` or `omnicore` resolution, without the development extra. The image records that choice in a root-owned read-only marker. Select the worker image that matches the configured setup SDK. -For every inference release, also publish the `agents` build separately as +For every model-proxy-enabled release, also publish the `agents` build separately as `WORKSTREAM_MODEL_PROXY_IMAGE`: the dedicated trusted proxy uses its pinned OpenAI 3.x input-count and Responses dependencies for both setup SDKs and POST checkers. The proxy rejects any non-`agents` image before reading the provider credential. `manager_authored` is accepted in either worker image with the exact -`none/none/none` model tuple and starts no proxy or credential surface. The backend image +`none/none/none` setup tuple; `WORKSTREAM_MODEL_PROXY_ENABLED` independently +decides whether approved POST images can use the proxy. The backend image also installs the official Roles Anywhere helper 1.8.5 at its fixed path, with architecture-specific hashes checked during the build and image proof. The checker-service build compiles the committed Rust lock and copies the pinned @@ -140,21 +141,24 @@ development authentication are absent. Create each secret selected by the configured runtime as a one-value regular file at the configured path. Make it owned by the runtime UID/GID with mode `0400`. The explicit role loader gives the API only the database, broker, rate-limit and pagination secrets; the worker -gets the database and broker. Inference runtimes additionally start one trusted -model-proxy process. Only that process gets the database, the selected provider -credential and the runtime ART identity. The setup worker receives a read-only +gets the database and broker. `WORKSTREAM_MODEL_PROXY_ENABLED=true` starts one +trusted model-proxy process independently of the setup runtime. Only that process +gets the database, the two closed provider credentials and the runtime ART identity. +The setup worker receives a read-only mount of `/var/lib/workstream/model-runtime` and the fixed socket selector; it -receives no provider credential or endpoint override. `manager_authored` omits -the proxy, selector, mount and secret definition. Beat +receives no provider credential or endpoint override. A proxy-disabled +`manager_authored` release omits both credential selectors, mounts and secret +definitions; a proxy-enabled one can still run approved POST model checks. Beat gets the database and broker secrets because its startup guard verifies the current artifact-provider activation before scheduling jobs. An absent required selector or unknown role stops startup, and an unrelated secret is neither mounted nor loaded. The loader independently requires the running identity and mode `0400`, performs no shell evaluation and never prints a value. Do not put AWS static keys in these files. -The provider selector is trusted release configuration. For `openrouter`, the -entrypoint loads the proxy-only file as `OPENROUTER_API_KEY`; for `openai`, it -loads the same selector as `OPENAI_API_KEY`. It clears both native variables -before selecting one and never exports a neutral key variable. The endpoint is +The two provider selectors are trusted release configuration. The proxy loads +only its dedicated files as `OPENROUTER_API_KEY` and `OPENAI_API_KEY`; this is +independent of the setup provider because approved POST and setup profiles may +use different providers. It clears both native variables before loading them +and never exports a neutral key variable. The endpoints are fixed by the registered adapter and cannot be supplied in the release file. The parser receives no database, broker, S3, Flow, model or provider credential. The release file also fixes the runtime, provider, model, model API, adapter @@ -294,9 +298,9 @@ python3 deploy/hosted/compose.py --env-file /etc/workstream/release.env \ python3 deploy/hosted/compose.py --env-file /etc/workstream/release.env ps ``` -The API applies forward migrations before serving. The guide parser and trusted -model proxy must pass their protocol/socket health checks before an inference -worker starts. Worker startup uses the existing prefork lifecycle. Beat runs the +The API applies forward migrations before serving. The guide parser and an +enabled trusted model proxy must pass their protocol/socket health checks before +the worker starts. Worker startup uses the existing prefork lifecycle. Beat runs the existing recovery and cleanup schedules. The checker health check requires its private socket and Docker's registered runsc runtime. It does not attest cache identity; full preflight diff --git a/deploy/hosted/compose.inference.yml b/deploy/hosted/compose.inference.yml index 08653028c..44bce55c0 100644 --- a/deploy/hosted/compose.inference.yml +++ b/deploy/hosted/compose.inference.yml @@ -13,14 +13,22 @@ services: model-proxy: environment: - WORKSTREAM_PROJECT_AGENT_API_KEY_FILE: /run/secrets/project_agent_api_key + WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE: /run/secrets/model_proxy_openai_api_key + WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE: /run/secrets/model_proxy_openrouter_api_key secrets: - - source: project_agent_api_key - target: project_agent_api_key + - source: model_proxy_openai_api_key + target: model_proxy_openai_api_key + uid: "${WORKSTREAM_RUNTIME_UID}" + gid: "${WORKSTREAM_RUNTIME_GID}" + mode: 0400 + - source: model_proxy_openrouter_api_key + target: model_proxy_openrouter_api_key uid: "${WORKSTREAM_RUNTIME_UID}" gid: "${WORKSTREAM_RUNTIME_GID}" mode: 0400 secrets: - project_agent_api_key: - file: ${WORKSTREAM_PROJECT_AGENT_API_KEY_FILE:?set the private project-agent provider key file} + model_proxy_openai_api_key: + file: ${WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE:?set the private model-proxy OpenAI key file} + model_proxy_openrouter_api_key: + file: ${WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE:?set the private model-proxy OpenRouter key file} diff --git a/deploy/hosted/compose.yml b/deploy/hosted/compose.yml index 575dd4136..0303e7850 100644 --- a/deploy/hosted/compose.yml +++ b/deploy/hosted/compose.yml @@ -306,7 +306,7 @@ services: model-proxy: <<: *backend-runtime - profiles: [inference] + profiles: [model-proxy] image: ${WORKSTREAM_MODEL_PROXY_IMAGE:?set the digest-qualified agents model-proxy image} environment: <<: *backend-environment diff --git a/deploy/hosted/preflight.py b/deploy/hosted/preflight.py index c87199feb..0215f5f3d 100644 --- a/deploy/hosted/preflight.py +++ b/deploy/hosted/preflight.py @@ -46,8 +46,11 @@ "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET_FILE", "WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET_FILE", ) -PROJECT_AGENT_SECRET_FILE_KEY = "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE" -SECRET_FILE_KEYS = (*BASE_SECRET_FILE_KEYS, PROJECT_AGENT_SECRET_FILE_KEY) +MODEL_PROXY_SECRET_FILE_KEYS = ( + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE", + "WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE", +) +SECRET_FILE_KEYS = (*BASE_SECRET_FILE_KEYS, *MODEL_PROXY_SECRET_FILE_KEYS) SECRET_FILE_TARGETS = { "WORKSTREAM_DATABASE_URL_FILE": ("database_url", "/run/secrets/database_url"), "WORKSTREAM_CELERY_BROKER_URL_FILE": ( @@ -62,9 +65,13 @@ "pagination_cursor_hmac", "/run/secrets/pagination_cursor_hmac", ), - "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE": ( - "project_agent_api_key", - "/run/secrets/project_agent_api_key", + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE": ( + "model_proxy_openai_api_key", + "/run/secrets/model_proxy_openai_api_key", + ), + "WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE": ( + "model_proxy_openrouter_api_key", + "/run/secrets/model_proxy_openrouter_api_key", ), } ROLE_SECRET_FILE_KEYS = { @@ -115,6 +122,7 @@ EXACT_RELEASE_VALUES = { "WORKSTREAM_ARTIFACT_S3_AWS_WORKLOAD_IDENTITY_METHOD": {"roles-anywhere"}, "WORKSTREAM_TOKEN_INTROSPECTION_MODE": {"disabled"}, + "WORKSTREAM_MODEL_PROXY_ENABLED": {"true", "false"}, "WORKSTREAM_PROJECT_AGENT_RUNTIME": { "openai_agents_sdk", "omnicore_agent", @@ -197,6 +205,7 @@ "WORKSTREAM_BACKEND_IMAGE", "WORKSTREAM_MODEL_PROXY_IMAGE", "WORKSTREAM_CHECKER_SERVICE_IMAGE", + "WORKSTREAM_MODEL_PROXY_ENABLED", "WORKSTREAM_RUNTIME_UID", "WORKSTREAM_RUNTIME_GID", "WORKSTREAM_DOCKER_SOCKET_GID", @@ -225,7 +234,7 @@ "WORKSTREAM_OBSERVABILITY_LOG_LEVEL", "WORKSTREAM_OBSERVABILITY_OTLP_ENDPOINT", "WORKSTREAM_OBSERVABILITY_TRACE_SAMPLE_RATIO", - PROJECT_AGENT_SECRET_FILE_KEY, + *MODEL_PROXY_SECRET_FILE_KEYS, ) RELEASE_KEYS = frozenset((*REQUIRED_RELEASE_KEYS, *OPTIONAL_RELEASE_KEYS)) @@ -335,12 +344,7 @@ def validate_environment(values: dict[str, str]) -> None: raise PreflightError( "manager-authored project agent configuration is invalid" ) - if values.get(PROJECT_AGENT_SECRET_FILE_KEY): - raise PreflightError( - "manager-authored releases cannot select a provider credential" - ) else: - _required(values, PROJECT_AGENT_SECRET_FILE_KEY) if provider not in {"openai", "openrouter"}: raise PreflightError("unsupported project agent model provider") if model_api not in {"chat_completions", "responses"}: @@ -356,6 +360,14 @@ def validate_environment(values: dict[str, str]) -> None: ":" in model or model.casefold().startswith("openrouter/") ): raise PreflightError("OpenRouter model routing variants are unsupported") + model_proxy_enabled = values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true" + if runtime != "manager_authored" and not model_proxy_enabled: + raise PreflightError("inference setup requires the trusted model proxy") + if model_proxy_enabled: + for key in MODEL_PROXY_SECRET_FILE_KEYS: + _required(values, key) + elif any(values.get(key) for key in MODEL_PROXY_SECRET_FILE_KEYS): + raise PreflightError("disabled model proxy cannot select provider credentials") try: runtime_options = json.loads(values["WORKSTREAM_PROJECT_AGENT_RUNTIME_OPTIONS"]) except json.JSONDecodeError as exc: @@ -486,18 +498,15 @@ def _private_metadata(path: Path, *, kind: str, uid: int, gid: int, mode: int) - def _secret_file_keys(values: dict[str, str]) -> tuple[str, ...]: - if values["WORKSTREAM_PROJECT_AGENT_RUNTIME"] == "manager_authored": - return BASE_SECRET_FILE_KEYS - return SECRET_FILE_KEYS + if values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true": + return SECRET_FILE_KEYS + return BASE_SECRET_FILE_KEYS def _role_secret_file_keys(role: str, values: dict[str, str]) -> tuple[str, ...]: keys = ROLE_SECRET_FILE_KEYS[role] - if ( - role == "model-proxy" - and values["WORKSTREAM_PROJECT_AGENT_RUNTIME"] != "manager_authored" - ): - return (*keys, PROJECT_AGENT_SECRET_FILE_KEY) + if role == "model-proxy" and values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true": + return (*keys, *MODEL_PROXY_SECRET_FILE_KEYS) return keys @@ -507,8 +516,8 @@ def validate_private_host_inputs(values: dict[str, str]) -> None: gid = _positive_id(values, "WORKSTREAM_RUNTIME_GID") _private_metadata(SCRATCH_ROOT, kind="directory", uid=uid, gid=gid, mode=0o700) _private_metadata(SOCKET_ROOT, kind="directory", uid=uid, gid=gid, mode=0o700) - inference = values["WORKSTREAM_PROJECT_AGENT_RUNTIME"] != "manager_authored" - if inference: + model_proxy_enabled = values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true" + if model_proxy_enabled: _private_metadata( MODEL_SOCKET_ROOT, kind="directory", uid=uid, gid=gid, mode=0o700 ) @@ -534,7 +543,7 @@ def validate_private_host_inputs(values: dict[str, str]) -> None: or guide_socket.st_nlink != 1 ): raise PreflightError("guide parser socket has unsafe retained state") - if inference: + if model_proxy_enabled: try: model_socket = MODEL_SOCKET_PATH.lstat() except FileNotFoundError: @@ -563,7 +572,7 @@ def validate_private_host_inputs(values: dict[str, str]) -> None: SOCKET_ROOT.resolve(strict=True), GUIDE_SOCKET_ROOT.resolve(strict=True), ] - if inference: + if model_proxy_enabled: shared_roots.append(MODEL_SOCKET_ROOT.resolve(strict=True)) resolved_paths = [path.resolve(strict=True) for path in private_paths] except (OSError, RuntimeError) as exc: @@ -620,9 +629,9 @@ def _run_json( def _compose_file_arguments(values: dict[str, str]) -> list[str]: arguments = ["--file", str(COMPOSE_PATH)] - if values["WORKSTREAM_PROJECT_AGENT_RUNTIME"] != "manager_authored": + if values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true": arguments.extend(("--file", str(INFERENCE_COMPOSE_PATH))) - arguments.extend(("--profile", "inference")) + arguments.extend(("--profile", "model-proxy")) return arguments @@ -683,7 +692,7 @@ def run_shutdown(environment_path: Path, values: dict[str, str]) -> int: ["stop", "beat", "api"], ["stop", "--timeout", "7320", "worker"], ] - if values["WORKSTREAM_PROJECT_AGENT_RUNTIME"] != "manager_authored": + if values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true": phases.append(["stop", "--timeout", "7320", "model-proxy"]) phases.append(["stop", "guide-document-parser", "checker-service"]) for arguments in phases: @@ -832,8 +841,8 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: "checker-service", *PROOF_SERVICES, } - inference = values["WORKSTREAM_PROJECT_AGENT_RUNTIME"] != "manager_authored" - if inference: + model_proxy_enabled = values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true" + if model_proxy_enabled: expected_services.add("model-proxy") if not isinstance(services, dict) or set(services) != expected_services: raise PreflightError( @@ -879,7 +888,7 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: "checker-service", *PROOF_SERVICES, } - if inference: + if model_proxy_enabled: backend_runtime_services.add("model-proxy") for name in backend_runtime_services: if services[name]["user"] != runtime_identity: @@ -900,7 +909,7 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: if any(services[name]["image"] != backend_image for name in PROOF_SERVICES): raise PreflightError("AWS proof jobs must use the exact backend image") if ( - inference + model_proxy_enabled and services["model-proxy"]["image"] != values["WORKSTREAM_MODEL_PROXY_IMAGE"] ): raise PreflightError("model proxy must use the exact agents backend image") @@ -910,7 +919,7 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: "beat", "guide-document-parser", *PROOF_SERVICES, - *({"model-proxy"} if inference else set()), + *({"model-proxy"} if model_proxy_enabled else set()), ): if services[name].get("entrypoint") is not None: raise PreflightError(f"backend image entrypoint is overridden: {name}") @@ -947,7 +956,7 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: | {str(GUIDE_SOCKET_ROOT): (str(GUIDE_SOCKET_ROOT), True)} | ( {str(MODEL_SOCKET_ROOT): (str(MODEL_SOCKET_ROOT), True)} - if inference + if model_proxy_enabled else {} ) | runtime_identity_mounts, @@ -986,7 +995,7 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: }, "aws-proof-coordinate": {}, } - if inference: + if model_proxy_enabled: expected_mounts["model-proxy"] = { str(SCRATCH_ROOT): (str(SCRATCH_ROOT), False), str(MODEL_SOCKET_ROOT): (str(MODEL_SOCKET_ROOT), False), @@ -1103,7 +1112,7 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: "guide-document-parser" ) != {"condition": "service_healthy", "required": True}: raise PreflightError("worker must wait for a healthy guide parser") - if inference and worker_dependencies.get("model-proxy") != { + if model_proxy_enabled and worker_dependencies.get("model-proxy") != { "condition": "service_healthy", "required": True, }: @@ -1120,10 +1129,10 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: ) ): raise PreflightError("project guide worker delivery profile is invalid") - if inference: + if model_proxy_enabled: proxy = services["model-proxy"] if ( - proxy.get("profiles") != ["inference"] + proxy.get("profiles") != ["model-proxy"] or proxy.get("ports") or proxy.get("command") != ["python", "-m", "app.workers.checker_model_proxy"] @@ -1181,7 +1190,9 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: ): raise PreflightError(f"{name} must receive only the database secret") environments: dict[str, dict[str, Any]] = {} - active_roles = ("api", "worker", "beat") + (("model-proxy",) if inference else ()) + active_roles = ("api", "worker", "beat") + ( + ("model-proxy",) if model_proxy_enabled else () + ) for role in active_roles: environment = services[role].get("environment") if not isinstance(environment, dict): @@ -1265,7 +1276,7 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: "worker", "beat", *PROOF_SERVICES, - *(("model-proxy",) if inference else ()), + *(("model-proxy",) if model_proxy_enabled else ()), ): service_environment = services[name].get("environment") if not isinstance(service_environment, dict): @@ -1281,11 +1292,11 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: GUIDE_SOCKET_PATH ): raise PreflightError("worker lacks the fixed guide parser socket") - if inference and worker_environment.get( + if model_proxy_enabled and worker_environment.get( "WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET" ) != str(MODEL_SOCKET_PATH): raise PreflightError("worker lacks the fixed model proxy socket") - if inference and environments["model-proxy"].get( + if model_proxy_enabled and environments["model-proxy"].get( "WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET" ) != str(MODEL_SOCKET_PATH): raise PreflightError("model proxy lacks its fixed socket") @@ -1295,15 +1306,15 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: raise PreflightError(f"{name} release environment is invalid") if "WORKSTREAM_GUIDE_DOCUMENT_PARSER_SOCKET" in service_environment: raise PreflightError(f"{name} must not receive the guide parser socket") - if "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE" in service_environment: + if any(key in service_environment for key in MODEL_PROXY_SECRET_FILE_KEYS): raise PreflightError( - f"{name} must not receive the project agent credential selector" + f"{name} must not receive a model proxy credential selector" ) if "WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET" in service_environment: raise PreflightError(f"{name} must not receive the model proxy socket") - if "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE" in worker_environment: + if any(key in worker_environment for key in MODEL_PROXY_SECRET_FILE_KEYS): raise PreflightError( - "worker must not receive the project agent credential selector" + "worker must not receive a model proxy credential selector" ) for name, service in services.items(): service_environment = service.get("environment") or {} diff --git a/deploy/hosted/release.env.example b/deploy/hosted/release.env.example index 7cc1aa580..05607669a 100644 --- a/deploy/hosted/release.env.example +++ b/deploy/hosted/release.env.example @@ -3,6 +3,7 @@ COMPOSE_PROJECT_NAME=workstream-release WORKSTREAM_BACKEND_IMAGE=registry.example/workstream/backend@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa WORKSTREAM_MODEL_PROXY_IMAGE=registry.example/workstream/backend-agents@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd +WORKSTREAM_MODEL_PROXY_ENABLED=true WORKSTREAM_CHECKER_SERVICE_IMAGE=registry.example/workstream/checker-service@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb WORKSTREAM_RUNTIME_UID=10001 @@ -17,13 +18,15 @@ WORKSTREAM_DATABASE_URL_FILE=/etc/workstream/secrets/database_url WORKSTREAM_CELERY_BROKER_URL_FILE=/etc/workstream/secrets/celery_broker_url WORKSTREAM_API_RATE_LIMIT_KEY_SECRET_FILE=/etc/workstream/secrets/api_rate_limit_key WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET_FILE=/etc/workstream/secrets/pagination_cursor_hmac -# Required only for inference runtimes. Omit this line entirely when the runtime -# is manager_authored. -WORKSTREAM_PROJECT_AGENT_API_KEY_FILE=/etc/workstream/secrets/project_agent_api_key +# Required only when WORKSTREAM_MODEL_PROXY_ENABLED=true. They are independent +# of the setup SDK/provider because approved POST profiles may select OpenAI +# while setup selects OpenRouter. Omit both when the proxy is disabled. +WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE=/etc/workstream/secrets/model_proxy_openai_api_key +WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE=/etc/workstream/secrets/model_proxy_openrouter_api_key # The runtime, provider, model and bounded effective profile are public trusted -# configuration. Only the dedicated trusted model-proxy entrypoint maps the one -# provider credential file to the selected provider's native variable. The proxy +# configuration. Only the dedicated trusted model-proxy entrypoint maps the two +# closed provider credential files to native variables. The proxy # image is always built with the pinned `agents` dependency set; workers may use # either mutually exclusive project-agent SDK image and never receive the key. WORKSTREAM_PROJECT_AGENT_RUNTIME=openai_agents_sdk diff --git a/docker/backend/release-entrypoint.sh b/docker/backend/release-entrypoint.sh index 00a9e5985..e657004d5 100644 --- a/docker/backend/release-entrypoint.sh +++ b/docker/backend/release-entrypoint.sh @@ -104,24 +104,8 @@ case "$role" in ;; model-proxy) load_required_secret WORKSTREAM_DATABASE_URL "${WORKSTREAM_DATABASE_URL_FILE:-}" - case "$runtime" in - manager_authored) - echo "manager-authored releases do not run a model proxy" >&2 - exit 1 - ;; - *) case "$provider" in - openrouter) - load_required_secret OPENROUTER_API_KEY "${WORKSTREAM_PROJECT_AGENT_API_KEY_FILE:-}" - ;; - openai) - load_required_secret OPENAI_API_KEY "${WORKSTREAM_PROJECT_AGENT_API_KEY_FILE:-}" - ;; - *) - echo "trusted project agent model provider is unavailable or unsupported" >&2 - exit 1 - ;; - esac ;; - esac + load_required_secret OPENAI_API_KEY "${WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE:-}" + load_required_secret OPENROUTER_API_KEY "${WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE:-}" ;; beat) load_required_secret WORKSTREAM_DATABASE_URL "${WORKSTREAM_DATABASE_URL_FILE:-}" @@ -144,7 +128,8 @@ unset WORKSTREAM_DATABASE_URL_FILE unset WORKSTREAM_CELERY_BROKER_URL_FILE unset WORKSTREAM_API_RATE_LIMIT_KEY_SECRET_FILE unset WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET_FILE -unset WORKSTREAM_PROJECT_AGENT_API_KEY_FILE +unset WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE +unset WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE unset OPENAI_API_KEY_FILE unset AWS_SHARED_CREDENTIALS_FILE unset AWS_CONFIG_FILE diff --git a/scripts/test_hosted_release_config.py b/scripts/test_hosted_release_config.py index 69152aada..9a831b72b 100644 --- a/scripts/test_hosted_release_config.py +++ b/scripts/test_hosted_release_config.py @@ -902,8 +902,10 @@ def test_environment_accepts_only_registered_runtime_provider_tuples(self) -> No "WORKSTREAM_PROJECT_AGENT_MODEL_API": "none", "WORKSTREAM_PROJECT_AGENT_MODEL": "none", "WORKSTREAM_PROJECT_AGENT_RUNTIME_OPTIONS": "{}", + "WORKSTREAM_MODEL_PROXY_ENABLED": "false", } - manager.pop("WORKSTREAM_PROJECT_AGENT_API_KEY_FILE") + for key in preflight.MODEL_PROXY_SECRET_FILE_KEYS: + manager.pop(key) preflight.validate_environment(manager) for changed in ( omni | {"WORKSTREAM_PROJECT_AGENT_MODEL_PROVIDER": "openai"}, @@ -929,6 +931,9 @@ def test_manager_authored_render_has_no_provider_credential_surface(self) -> Non "WORKSTREAM_PROJECT_AGENT_MODEL_API=chat_completions": ( "WORKSTREAM_PROJECT_AGENT_MODEL_API=none" ), + "WORKSTREAM_MODEL_PROXY_ENABLED=true": ( + "WORKSTREAM_MODEL_PROXY_ENABLED=false" + ), } for old, new in replacements.items(): manager_text = manager_text.replace(old, new) @@ -936,7 +941,10 @@ def test_manager_authored_render_has_no_provider_credential_surface(self) -> Non "\n".join( line for line in manager_text.splitlines() - if not line.startswith("WORKSTREAM_PROJECT_AGENT_API_KEY_FILE=") + if not line.startswith("WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE=") + and not line.startswith( + "WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE=" + ) ) + "\n" ) @@ -948,22 +956,88 @@ def test_manager_authored_render_has_no_provider_credential_surface(self) -> Non rendered = preflight.render_compose(manager_path, manager) preflight.validate_compose(rendered, manager) - self.assertNotIn("project_agent_api_key", rendered.get("secrets", {})) + self.assertNotIn("model_proxy_openai_api_key", rendered.get("secrets", {})) + self.assertNotIn("model_proxy_openrouter_api_key", rendered.get("secrets", {})) worker = rendered["services"]["worker"] - self.assertNotIn("WORKSTREAM_PROJECT_AGENT_API_KEY_FILE", worker["environment"]) + for key in preflight.MODEL_PROXY_SECRET_FILE_KEYS: + self.assertNotIn(key, worker["environment"]) self.assertNotIn( - "project_agent_api_key", + "model_proxy_openai_api_key", + {item["source"] for item in worker["secrets"]}, + ) + self.assertNotIn( + "model_proxy_openrouter_api_key", {item["source"] for item in worker["secrets"]}, ) selected = manager | { - "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE": str(self.root / "provider") + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE": str(self.root / "provider") } - with self.assertRaisesRegex( - preflight.PreflightError, "cannot select a provider credential" - ): + with self.assertRaisesRegex(preflight.PreflightError, "disabled model proxy"): preflight.validate_environment(selected) + def test_manager_setup_can_enable_post_proxy_with_both_provider_credentials( + self, + ) -> None: + manager_text = self.environment_path.read_text(encoding="utf-8") + for old, new in { + "WORKSTREAM_PROJECT_AGENT_RUNTIME=openai_agents_sdk": ( + "WORKSTREAM_PROJECT_AGENT_RUNTIME=manager_authored" + ), + "WORKSTREAM_PROJECT_AGENT_MODEL_PROVIDER=openrouter": ( + "WORKSTREAM_PROJECT_AGENT_MODEL_PROVIDER=none" + ), + "WORKSTREAM_PROJECT_AGENT_MODEL=openai/gpt-5.6-terra": ( + "WORKSTREAM_PROJECT_AGENT_MODEL=none" + ), + "WORKSTREAM_PROJECT_AGENT_MODEL_API=chat_completions": ( + "WORKSTREAM_PROJECT_AGENT_MODEL_API=none" + ), + }.items(): + manager_text = manager_text.replace(old, new) + manager_path = self.root / "manager-with-post-proxy.env" + manager_path.write_text(manager_text, encoding="utf-8") + manager = preflight.parse_environment(manager_path) + + preflight.validate_environment(manager) + rendered = preflight.render_compose(manager_path, manager) + preflight.validate_compose(rendered, manager) + + self.assertIn("model-proxy", rendered["services"]) + self.assertEqual( + {item["source"] for item in rendered["services"]["model-proxy"]["secrets"]}, + { + "database_url", + "model_proxy_openai_api_key", + "model_proxy_openrouter_api_key", + }, + ) + worker_environment = rendered["services"]["worker"]["environment"] + for key in preflight.MODEL_PROXY_SECRET_FILE_KEYS: + self.assertNotIn(key, worker_environment) + + def test_openrouter_setup_keeps_independent_openai_post_credential(self) -> None: + self.assertEqual( + self.environment["WORKSTREAM_PROJECT_AGENT_MODEL_PROVIDER"], "openrouter" + ) + rendered = preflight.render_compose(self.environment_path, self.environment) + preflight.validate_compose(rendered, self.environment) + + proxy = rendered["services"]["model-proxy"] + self.assertEqual( + { + item["source"] + for item in proxy["secrets"] + if item["source"] != "database_url" + }, + { + "model_proxy_openai_api_key", + "model_proxy_openrouter_api_key", + }, + ) + for key in preflight.MODEL_PROXY_SECRET_FILE_KEYS: + self.assertNotIn(key, rendered["services"]["worker"]["environment"]) + def test_checker_config_rejects_non_hosted_or_substituted_identity(self) -> None: cases = { "runc": ("runtime", "runc"), @@ -1039,8 +1113,8 @@ def test_rendered_topology_rejects_privilege_and_path_drift(self) -> None: cases.append(("missing role secret selector", missing_role_secret)) unrelated_role_secret = copy.deepcopy(rendered) unrelated_role_secret["services"]["beat"]["environment"][ - "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE" - ] = "/run/secrets/project_agent_api_key" + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE" + ] = "/run/secrets/model_proxy_openai_api_key" cases.append(("unrelated role secret selector", unrelated_role_secret)) unrelated_role_mount = copy.deepcopy(rendered) unrelated_role_mount["services"]["beat"]["secrets"].append( @@ -1168,8 +1242,8 @@ def test_rendered_topology_rejects_privilege_and_path_drift(self) -> None: cases.append(("guide parser socket exposed to proof", proof_parser_socket)) proof_provider_selector = copy.deepcopy(rendered) proof_provider_selector["services"]["aws-proof-coordinate"]["environment"][ - "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE" - ] = "/run/secrets/project_agent_api_key" + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE" + ] = "/run/secrets/model_proxy_openai_api_key" cases.append(("provider selector exposed to proof", proof_provider_selector)) worker_provider_value = copy.deepcopy(rendered) worker_provider_value["services"]["worker"]["environment"][ @@ -1334,7 +1408,7 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( scratch_secret.chmod(0o400) scratch_alias = self.root / "scratch-alias" scratch_alias.symlink_to(scratch, target_is_directory=True) - environment["WORKSTREAM_PROJECT_AGENT_API_KEY_FILE"] = str( + environment["WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE"] = str( scratch_alias / scratch_secret.name ) with ( @@ -1360,7 +1434,9 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( ), ): preflight.validate_private_host_inputs(environment) - environment["WORKSTREAM_PROJECT_AGENT_API_KEY_FILE"] = str(secret_paths[-1]) + environment["WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE"] = str( + secret_paths[-1] + ) socket_identity = socket_root / "negative-key.pem" socket_identity.write_text("private", encoding="utf-8") socket_identity.chmod(0o400) @@ -1491,7 +1567,8 @@ class ReleaseSecretLoaderTests(unittest.TestCase): "WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET_FILE": ( "WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET" ), - "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE": "OPENROUTER_API_KEY", + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE": "OPENAI_API_KEY", + "WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE": "OPENROUTER_API_KEY", } ROLE_FILES = { "api": ( @@ -1506,7 +1583,8 @@ class ReleaseSecretLoaderTests(unittest.TestCase): ), "model-proxy": ( "WORKSTREAM_DATABASE_URL_FILE", - "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE", + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE", + "WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE", ), "beat": ( "WORKSTREAM_DATABASE_URL_FILE", @@ -1535,7 +1613,10 @@ def _run_loader( "WORKSTREAM_CELERY_BROKER_URL_FILE": "broker", "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET_FILE": "rate", "WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET_FILE": "cursor", - "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE": "project-agent", + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE": "model-proxy-openai", + "WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE": ( + "model-proxy-openrouter" + ), } for variable, name in file_names.items(): path = root / name @@ -1544,8 +1625,6 @@ def _run_loader( path.write_text(content, encoding="utf-8") path.chmod(0o400) environment[variable] = str(path) - if runtime == "manager_authored": - environment.pop("WORKSTREAM_PROJECT_AGENT_API_KEY_FILE", None) for secret_name in { *self.FILE_TO_SECRET.values(), "OPENAI_API_KEY", @@ -1587,9 +1666,6 @@ def _run_loader( ) environment["EXPECT_PARSER_SELECTOR_SCRUB"] = "1" if role == "parser" else "0" expected = {self.FILE_TO_SECRET[key] for key in self.ROLE_FILES.get(role, ())} - if role == "model-proxy" and provider == "openai": - expected.remove("OPENROUTER_API_KEY") - expected.add("OPENAI_API_KEY") environment["EXPECTED_SECRET_NAMES"] = ",".join(sorted(expected)) environment["EXPECTED_SECRET_SHA256"] = hashlib.sha256( content.encode() @@ -1679,9 +1755,14 @@ def test_loader_loads_only_role_secrets_and_rejects_missing_or_unknown_role( ) openai = self._run_loader(root, role="model-proxy", provider="openai") self.assertEqual(openai.returncode, 0, openai.stderr) - unsupported = self._run_loader(root, role="model-proxy", provider="unknown") - self.assertNotEqual(unsupported.returncode, 0) - self.assertIn("model provider", unsupported.stderr) + setup_provider_independent = self._run_loader( + root, role="model-proxy", provider="unknown" + ) + self.assertEqual( + setup_provider_independent.returncode, + 0, + setup_provider_independent.stderr, + ) unknown = self._run_loader(root, role="unknown") self.assertNotEqual(unknown.returncode, 0) self.assertEqual(unknown.stdout, "") @@ -1703,6 +1784,14 @@ def test_loader_binds_runtime_to_sdk_marker_before_provider_secret(self) -> None sdk_environment="agents", ) self.assertEqual(omni_proxy.returncode, 0, omni_proxy.stderr) + manager_proxy = self._run_loader( + root, + role="model-proxy", + runtime="manager_authored", + provider="none", + sdk_environment="agents", + ) + self.assertEqual(manager_proxy.returncode, 0, manager_proxy.stderr) for sdk_environment in ("agents", "omnicore"): with self.subTest( runtime="manager_authored", sdk_environment=sdk_environment From 60504dce74299d0a2c311ddf7edfa69bee4e2e7c Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:02:02 +0100 Subject: [PATCH 032/115] test(setup): expose pre-settlement failures --- .../test_setup_model_handler_postgresql.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py index c78100ab8..4f0f4f36c 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py +++ b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py @@ -503,7 +503,8 @@ async def _state(journey: _Journey): "account.lifetime_settled_nanodollars " "from model_usage_effects effect " "join setup_model_usage_slots slot on slot.id=effect.setup_slot_id " - "join model_usage_budget_accounts account on account.project_id=slot.project_id " + "left join model_usage_budget_accounts account " + "on account.project_id=slot.project_id " "where slot.compilation_attempt_id=:attempt" ), {"attempt": str(journey.request.subject.compilation_attempt_id)}, @@ -607,6 +608,10 @@ async def test_setup_response_recovers_from_verified_minio_after_ledger_loss( journey = await _journey(storage, admin_access) first = await journey.handler().handle(journey.request) assert first.outcome == "unavailable" + assert first.failure_code == "model_call_unavailable" + assert journey.reserve_observations == [("setup_model_call", "model_request", "verified")] + assert journey.provider_calls == ["dispatch"] + assert journey.fail_completion == [False] uncertain = await _state(journey) assert uncertain.state == "uncertain" assert uncertain.response_binding_id is None From 5bbf1e0f01f5b791aa1bda420c20be76c8c769f7 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:02:06 +0100 Subject: [PATCH 033/115] fix(checkers): validate model proxy dependencies in CLI --- .../app/core/project_guide_instructions.py | 10 +++---- cli/internal/api/guide_proposal.go | 8 +++-- .../integration/test_guide_proposal_http.py | 29 +++++++++++++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/backend/app/core/project_guide_instructions.py b/backend/app/core/project_guide_instructions.py index 407ca7eba..cd4fc243f 100644 --- a/backend/app/core/project_guide_instructions.py +++ b/backend/app/core/project_guide_instructions.py @@ -317,11 +317,11 @@ capacity, deadline, provider failure and invalid output cannot consume revision rounds. DEPENDENCIES -Specs grant no access. Images receive verified material, manifest, configuration -and request metadata, not guide prose, provider -keys or nested environments. POST model_evaluation requires model_service/model_proxy, -implementation_unavailable, a registered image and exact profile; else stays -unbound. Declare unavailable needs explicitly; +Specs give no access. Images get verified material, manifest, config and +request, not guide prose, provider keys or nested environments. POST +model_evaluation requires model_service/model_proxy, implementation_unavailable, +a registered project image and its exact approved profile; else stays unbound. +Declare unavailable needs explicitly; a supported binding cannot depend on runtime_gap. current_runner isolates the checker image only; it supplies no container engine, second sandbox or host socket. Follow the schema's exact dependency kind/provisioning/unavailable_code pairs. diff --git a/cli/internal/api/guide_proposal.go b/cli/internal/api/guide_proposal.go index f5bf05938..efc52b5b6 100644 --- a/cli/internal/api/guide_proposal.go +++ b/cli/internal/api/guide_proposal.go @@ -587,11 +587,15 @@ func validateProposalCheckSpecification(raw json.RawMessage, value *ProposalChec "nested_sandbox": {"runtime_gap", "implementation_unavailable"}, "task_context": {"runtime_gap", "material_unavailable"}, "guide_context": {"runtime_gap", "material_unavailable"}, - "model_service": {"runtime_gap", "implementation_unavailable"}, "execution_capacity": {"runtime_gap", "capacity_exceeded"}, "prepared_material": {"runtime_gap", "material_unavailable"}, }[item.Kind] - if expected == [2]string{} || item.Provisioning != expected[0] || item.UnavailableCode != expected[1] || (supported && item.Provisioning == "runtime_gap") { + valid := expected != [2]string{} && item.Provisioning == expected[0] && item.UnavailableCode == expected[1] + if item.Kind == "model_service" { + valid = item.UnavailableCode == "implementation_unavailable" && + (item.Provisioning == "model_proxy" || (!supported && item.Provisioning == "runtime_gap")) + } + if !valid || (supported && item.Provisioning == "runtime_gap") { return errors.New("invalid runtime dependency provisioning") } dependencyIDs[item.DependencyID], dependencyKinds[item.Kind] = true, true diff --git a/cli/tests/integration/test_guide_proposal_http.py b/cli/tests/integration/test_guide_proposal_http.py index e591f82e7..9b45f99bc 100644 --- a/cli/tests/integration/test_guide_proposal_http.py +++ b/cli/tests/integration/test_guide_proposal_http.py @@ -812,6 +812,35 @@ def test_proposal_rejects_pre_model_evaluation_and_validates_post_runtime_gaps(c assert_failure(invoke(cli, origin), "invalid_api_response") +def test_proposal_accepts_supported_execution_with_model_proxy(cli): + from app.modules.projects.api.guide_proposal_package import ( + GuideProposalReviewPackage, + ) + + with http_fixture() as (origin, response, _): + value = proposal() + dependencies = value["result"]["requirements"][1]["check_specification"][ + "runtime_dependencies" + ] + dependencies.append( + { + "dependency_id": "quality_model", + "kind": "model_service", + "provisioning": "model_proxy", + "purpose": "Assess the authenticated execution observations.", + "unavailable_code": "implementation_unavailable", + } + ) + GuideProposalReviewPackage.model_validate(value) + response["body"] = json.dumps(value).encode() + accepted = invoke(cli, origin) + assert accepted.returncode == 0 and json.loads(accepted.stdout) == value + + dependencies[-1]["provisioning"] = "runtime_gap" + response["body"] = json.dumps(value).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + + def test_proposal_rejects_malformed_nested_types_and_incomplete_policy_identity(cli): cases = [ (("current",), "yes"), From 60bb501ae9e938e259df8fc286808dcd984e4bc9 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:10:32 +0100 Subject: [PATCH 034/115] test: expose setup model journey failure stage --- .../test_setup_model_handler_postgresql.py | 102 ++++++++++++++++-- 1 file changed, 96 insertions(+), 6 deletions(-) diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py index 4f0f4f36c..0853b0b21 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py +++ b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py @@ -367,20 +367,26 @@ class _Journey: response_marker: str provider_calls: list[str] reserve_observations: list[tuple[str, str, str]] + stages: list[str] + failures: list[BaseException] fail_completion: list[bool] outputs: object def handler(self) -> SetupModelProxyHandler: - return SetupModelProxyHandler( + return _ObservedHandler( + failures=self.failures, sessions=self.storage.factory, authorization=setup_model_service_authorization, calls=lambda session, authority: _ObservedCalls(session, authority, self), profiles=model_profile_reader, outputs=self.outputs, - bindings=lambda session: setup_model_output_binding( - session, - namespace=self.storage.namespace, - owner=setup_model_usage_owner(session), + bindings=lambda session: _ObservedBindings( + setup_model_output_binding( + session, + namespace=self.storage.namespace, + owner=setup_model_usage_owner(session), + ), + self.stages, ), provider=lambda profile: _provider(self, profile), ) @@ -391,6 +397,7 @@ def __init__(self, journey: _Journey) -> None: self.journey = journey async def count_input(self, _body): + self.journey.stages.append("count_input") return 1 async def dispatch(self, _body): @@ -417,6 +424,19 @@ async def _provider(journey: _Journey, _profile) -> AsyncIterator[_Provider]: yield _Provider(journey) +class _ObservedHandler(SetupModelProxyHandler): + def __init__(self, *, failures: list[BaseException], **kwargs) -> None: + super().__init__(**kwargs) + self.failures = failures + + async def _handle(self, request): + try: + return await super()._handle(request) + except Exception as exc: + self.failures.append(exc) + raise + + class _ObservedCalls: def __init__(self, session, authority, journey: _Journey) -> None: self.session = session @@ -431,7 +451,20 @@ def __init__(self, session, authority, journey: _Journey) -> None: def __getattr__(self, name): return getattr(self.owner, name) + async def recover_existing(self, *args, **kwargs): + self.journey.stages.append("recover_existing") + result = await self.owner.recover_existing(*args, **kwargs) + self.journey.stages.append("recover_existing_done") + return result + + async def prepare(self, *args, **kwargs): + self.journey.stages.append("prepare") + result = await self.owner.prepare(*args, **kwargs) + self.journey.stages.append("prepare_done") + return result + async def reserve_dispatch(self, prepared, request_evidence): + self.journey.stages.append("reserve_dispatch") row = ( await self.session.execute( text( @@ -456,6 +489,46 @@ async def complete(self, *args, **kwargs): return await self.owner.complete(*args, **kwargs) +class _ObservedOutputs: + def __init__(self, owner, stages: list[str]) -> None: + self.owner = owner + self.stages = stages + + def __getattr__(self, name): + return getattr(self.owner, name) + + async def store_setup_output(self, request): + role = request.selector.role + self.stages.append(f"store_{role}") + try: + result = await self.owner.store_setup_output(request) + except Exception as exc: + self.stages.append(f"store_{role}_failed:{type(exc).__name__}:{exc}") + raise + self.stages.append(f"store_{role}_done") + return result + + +class _ObservedBindings: + def __init__(self, owner, stages: list[str]) -> None: + self.owner = owner + self.stages = stages + + def __getattr__(self, name): + return getattr(self.owner, name) + + async def bind_setup_output(self, request): + role = request.selector.role + self.stages.append(f"bind_{role}") + try: + result = await self.owner.bind_setup_output(request) + except Exception as exc: + self.stages.append(f"bind_{role}_failed:{type(exc).__name__}:{exc}") + raise + self.stages.append(f"bind_{role}_done") + return result + + async def _journey(storage: _StorageCase, access) -> _Journey: profile = await _publish_profile(access, storage.values["project"]) configuration = _runtime(profile) @@ -484,7 +557,21 @@ async def _journey(storage: _StorageCase, access) -> _Journey: settings=storage.settings, owner=setup_model_usage_owner, ) - return _Journey(storage, profile, request, response, marker, [], [], [True], outputs) + stages: list[str] = [] + observed_outputs = _ObservedOutputs(outputs, stages) + return _Journey( + storage, + profile, + request, + response, + marker, + [], + [], + stages, + [], + [True], + observed_outputs, + ) async def _state(journey: _Journey): @@ -609,6 +696,9 @@ async def test_setup_response_recovers_from_verified_minio_after_ledger_loss( first = await journey.handler().handle(journey.request) assert first.outcome == "unavailable" assert first.failure_code == "model_call_unavailable" + assert [f"{type(exc).__name__}:{exc}" for exc in journey.failures] == [ + "ModelCallUnavailable:simulated_setup_ledger_unavailable" + ] assert journey.reserve_observations == [("setup_model_call", "model_request", "verified")] assert journey.provider_calls == ["dispatch"] assert journey.fail_completion == [False] From 655194b49864be617be3999fc4b56cf8a8898b19 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:14:37 +0100 Subject: [PATCH 035/115] fix(checkers): keep model-assisted checks in POST --- backend/app/interfaces/project_agents.py | 7 ++- .../test_check_specifications.py | 53 ++++++++++++++++++- cli/internal/api/guide_proposal.go | 3 ++ .../integration/test_guide_proposal_http.py | 19 +++++++ 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/backend/app/interfaces/project_agents.py b/backend/app/interfaces/project_agents.py index 233a9f1a7..1056f7871 100644 --- a/backend/app/interfaces/project_agents.py +++ b/backend/app/interfaces/project_agents.py @@ -963,8 +963,11 @@ def require_complete_generated_specifications( if specification is None: raise ValueError("check requirement lacks a check specification") if expected_stage is CompilationStage.PRE_SUBMIT: - if specification.evaluation_method == "model_evaluation": - raise ValueError("model evaluation requires post-submit background work") + if specification.evaluation_method == "model_evaluation" or any( + dependency.kind == "model_service" + for dependency in specification.runtime_dependencies + ): + raise ValueError("model service requires post-submit background work") if ( specification.execution_mode != "bounded_pre_submit" or specification.maximum_duration_seconds > 60 diff --git a/backend/tests/projects/guide_compilation/test_check_specifications.py b/backend/tests/projects/guide_compilation/test_check_specifications.py index 6e8dc36e5..983181a32 100644 --- a/backend/tests/projects/guide_compilation/test_check_specifications.py +++ b/backend/tests/projects/guide_compilation/test_check_specifications.py @@ -271,12 +271,63 @@ def test_model_evaluation_requires_post_even_with_a_short_duration(stage: str) - compilation_context = context(ids()) validate_project_guide_compilation_result(compilation_context, proposal) if stage == "pre_submit": - with pytest.raises(ValueError, match="model evaluation requires post-submit"): + with pytest.raises(ValueError, match="model service requires post-submit"): require_complete_generated_specifications(compilation_context, proposal) else: require_complete_generated_specifications(compilation_context, proposal) +def test_model_assisted_isolated_execution_requires_post_submit() -> None: + specification = _specification( + evaluation_method="isolated_execution", + execution_mode="bounded_pre_submit", + maximum_duration_seconds=30, + runtime_dependencies=[ + { + "dependency_id": "execution_runner", + "kind": "isolated_execution", + "provisioning": "current_runner", + "purpose": "Execute the bounded trial.", + "unavailable_code": "implementation_unavailable", + }, + { + "dependency_id": "model_judge", + "kind": "model_service", + "provisioning": "model_proxy", + "purpose": "Assess the authenticated trial observations.", + "unavailable_code": "implementation_unavailable", + }, + ], + infrastructure_failures=[ + { + "code": "implementation_unavailable", + "condition": "The execution or model service is unavailable.", + } + ], + ) + requirement = _requirement( + disposition="pre_submit_capability_gap", + check_specification=specification.model_dump(mode="json"), + ) + proposal = _with_requirement(requirement).model_copy( + update={ + "status": "draft_ready_with_warnings", + "capability_suggestions": ( + CapabilitySuggestion( + requirement_id=requirement.requirement_id, + stage="pre_submit", + title="Trial assessment", + rationale="The trial requires model-assisted assessment.", + evidence_refs=requirement.evidence_refs, + ), + ), + } + ) + + with pytest.raises(ValueError, match="model service requires post-submit"): + require_complete_generated_specifications(context(ids()), proposal) + + def test_new_generation_rejects_legacy_model_runtime_gap() -> None: specification = _specification( evaluation_method="model_evaluation", diff --git a/cli/internal/api/guide_proposal.go b/cli/internal/api/guide_proposal.go index efc52b5b6..9e739e4e8 100644 --- a/cli/internal/api/guide_proposal.go +++ b/cli/internal/api/guide_proposal.go @@ -609,6 +609,9 @@ func validateProposalCheckSpecification(raw json.RawMessage, value *ProposalChec (value.EvaluationMethod == "isolated_execution" && !dependencyKinds["isolated_execution"]) { return errors.New("missing runtime dependency") } + if stage == "pre_submit" && dependencyKinds["model_service"] { + return errors.New("model service must run after submission") + } infraCodes := make(map[string]bool) infraMembers, err := proposalObjectMembers(fields["infrastructure_failures"], 1, 5) diff --git a/cli/tests/integration/test_guide_proposal_http.py b/cli/tests/integration/test_guide_proposal_http.py index 9b45f99bc..3d2060bfa 100644 --- a/cli/tests/integration/test_guide_proposal_http.py +++ b/cli/tests/integration/test_guide_proposal_http.py @@ -841,6 +841,25 @@ def test_proposal_accepts_supported_execution_with_model_proxy(cli): assert_failure(invoke(cli, origin), "invalid_api_response") +def test_proposal_rejects_pre_check_with_model_assistance(cli): + with http_fixture() as (origin, response, _): + value = proposal() + dependencies = value["result"]["requirements"][0]["check_specification"][ + "runtime_dependencies" + ] + dependencies.append( + { + "dependency_id": "quality_model", + "kind": "model_service", + "provisioning": "model_proxy", + "purpose": "Assist the bounded static inspection.", + "unavailable_code": "implementation_unavailable", + } + ) + response["body"] = json.dumps(value).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + + def test_proposal_rejects_malformed_nested_types_and_incomplete_policy_identity(cli): cases = [ (("current",), "yes"), From 68d0aed943af6e27f98b641a0ec30d2adeaf20ad Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:14:37 +0100 Subject: [PATCH 036/115] fix(setup): validate native Omni reasoning shape --- .../adapters/project_agents/omnicore_agent.py | 16 ++++++++++++++-- backend/tests/test_omnicore_agent_sdk.py | 19 +++++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/backend/app/adapters/project_agents/omnicore_agent.py b/backend/app/adapters/project_agents/omnicore_agent.py index 465ab928c..a1eb455f0 100644 --- a/backend/app/adapters/project_agents/omnicore_agent.py +++ b/backend/app/adapters/project_agents/omnicore_agent.py @@ -136,6 +136,7 @@ "messages", "temperature", "max_tokens", + "reasoning_effort", "top_p", "stream", "tools", @@ -908,7 +909,11 @@ async def _read_request(self, reader: asyncio.StreamReader) -> _ParsedRequest: raise _GuardRejection(400) from None if not isinstance(body, dict): raise _GuardRejection(400) - _validate_request_body(body, expected_model=self._model) + _validate_request_body( + body, + expected_model=self._model, + expected_reasoning_effort=self._reasoning_effort, + ) return _ParsedRequest(body=body) async def _forward(self, request: _ParsedRequest) -> tuple[int, Mapping[str, str], bytes]: @@ -1066,7 +1071,12 @@ def __init__(self, status: int) -> None: self.status = status -def _validate_request_body(body: dict[str, Any], *, expected_model: str) -> None: +def _validate_request_body( + body: dict[str, Any], + *, + expected_model: str, + expected_reasoning_effort: str, +) -> None: """Accept only the pinned LiteLLM request fields and configured model.""" if set(body) - _ALLOWED_BODY_FIELDS: raise _GuardRejection(400) @@ -1078,6 +1088,8 @@ def _validate_request_body(body: dict[str, Any], *, expected_model: str) -> None raise _GuardRejection(400) if body.get("stream", False) is not False: raise _GuardRejection(400) + if body.get("reasoning_effort") != expected_reasoning_effort: + raise _GuardRejection(400) if "tool_choice" in body and body["tool_choice"] != "auto": raise _GuardRejection(400) temperature = body.get("temperature") diff --git a/backend/tests/test_omnicore_agent_sdk.py b/backend/tests/test_omnicore_agent_sdk.py index a7cd512ce..30cffd652 100644 --- a/backend/tests/test_omnicore_agent_sdk.py +++ b/backend/tests/test_omnicore_agent_sdk.py @@ -372,7 +372,12 @@ async def opened_handles() -> frozenset[str]: opened_handles=opened_handles, attempts=attempts, rejections=rejections, - ) + ), + model=SimpleNamespace( + maximum_output_tokens=16_384, + reasoning_effort="medium", + text_verbosity="low", + ), ), reads, handle, @@ -445,8 +450,8 @@ def test_profile_provider_and_test_transport_are_closed(monkeypatch): compilation_context = _runtime_context() monkeypatch.delenv("OPENROUTER_API_KEY") - with pytest.raises(ProjectAgentRuntimeConfigurationError, match="credentials"): - OmniCoreAgentProjectGuideRuntime(compilation_context.runtime_configuration) + runtime = OmniCoreAgentProjectGuideRuntime(compilation_context.runtime_configuration) + assert runtime.identity == compilation_context.runtime_configuration.adapter_identity monkeypatch.setenv("OPENROUTER_API_KEY", "synthetic-omnicore-test-key") with pytest.raises(ProjectAgentRuntimeConfigurationError, match="loopback"): OmniCoreAgentProjectGuideRuntime._for_test( @@ -477,6 +482,7 @@ def test_profile_rejects_litellm_distribution_drift(): lambda body: body.update(plugins=[]), lambda body: body.update(model="openrouter/auto"), lambda body: body.update(usage={"include": False}), + lambda body: body.update(reasoning_effort="high"), lambda body: body.update(temperature=1), lambda body: body.update(max_tokens=1), lambda body: body.update(tools=[{"type": "web_search", "web_search": {}}]), @@ -491,12 +497,17 @@ def test_transport_rejects_each_routing_and_tool_mutation_before_forward(mutatio ], "temperature": 0, "max_tokens": 16_384, + "reasoning_effort": "medium", "tools": copy.deepcopy(_MODEL_TOOLS), "usage": {"include": True}, } mutation(body) with pytest.raises(_GuardRejection): - _validate_request_body(body, expected_model=OMNICORE_QUALIFICATION_MODEL) + _validate_request_body( + body, + expected_model=OMNICORE_QUALIFICATION_MODEL, + expected_reasoning_effort="medium", + ) def test_transport_rejects_duplicate_json_members(): From 273a8007abcb3084a893647e7fc8f6715cd677cf Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:17:04 +0100 Subject: [PATCH 037/115] docs(model-usage): record shared setup and POST runtime --- .commitrail/changes/checker-model-runtime.md | 69 ++--- backend/scripts/test_lane_catalogue.py | 5 + backend/tests/test_ci_lane_catalogue.py | 13 +- docs/operations_checker_models.md | 296 ++++++++++--------- docs/roadmap_status.md | 34 ++- 5 files changed, 231 insertions(+), 186 deletions(-) diff --git a/.commitrail/changes/checker-model-runtime.md b/.commitrail/changes/checker-model-runtime.md index 5743f06b4..48c0d31cd 100644 --- a/.commitrail/changes/checker-model-runtime.md +++ b/.commitrail/changes/checker-model-runtime.md @@ -15,16 +15,17 @@ strict draft/review/correction SDK loops. Workstream owns one model-use authorit project lifetime budget, provider effect and evidence boundary across both. No project rule or generic agent framework belongs in the shared Rust runner. -## Current behavior - -The unmerged branch already proves a checker-only OpenAI Responses call, project -budget, protected request/response evidence, provider-effect recovery and a -single-use invocation socket. Current main separately runs OpenAI Agents SDK and -OmniCoreAgent setup inference directly through their pinned native transports. -Neither side is the shared boundary: the checker profile is registry-bound and -one-call, while setup has no durable usage ledger. Generalize the unmerged schema -directly after main's `0059_neutral_guide_runtime`; do not preserve checker-only -development names or introduce a second meter. +## Intended merge behavior + +One MODEL-USAGE owner supplies immutable project profiles, a non-resetting +project-lifetime account, bounded run envelopes, paid effects and private ART +evidence to both setup inference and approved POST images. Setup retains its +native OpenAI Agents SDK and OmniCoreAgent loops through the trusted proxy; POST +retains project-owned rubrics and invocation-local transport. Strong typed setup +and checker subjects share the meter without fabricating a CheckerRun for setup. +The linear `0061_model_usage` migration follows main's +`0060_audit_restore_path`; intermediate upgrades retain main data and leave one +head. ## Bounded change @@ -39,10 +40,9 @@ development names or introduce a second meter. replacing either agent loop or forwarding arbitrary HTTP. - CHECKERS selected POST integration and existing AUTH, PROJECTS and TASK ports for fresh owner currentness and Operator provisioning. -- Existing ART generated checker-output custody to retain bounded private - request/response evidence, with matching authority and read concealment; the - same put/verify/bind/recover pipeline accepts one typed CHECKERS-owned trial - evidence subject so trusted observations are durable before a paid assessment. +- Existing ART generated-output custody to retain bounded private model + request/response evidence, with matching authority, read concealment and + exact setup/checker lineage. - Typed provider capability under `backend/app/interfaces/`, concrete adapter and explicit ADR-0014 composition; fixed settings outside images. - One linear Alembic continuation with preflight, intermediate upgrade and @@ -159,18 +159,10 @@ run-only write selector and deny-only producer composition are replaced; retaine historical rows keep their immutable ancestry. Private call bindings remain retained independently of the original checker run. -Execution tools use a separate immutable `checker_trial_evidence` owner selected -after a canonical plan is validated and before its engine runs. Its replay key is -the stable project/task/assignment/Submission, requirement, registry/configuration, -trial kind/slot, exact plan and input/material digests. The originating run, lease -and external-request digest are retained provenance but never the replay key. -The first completed canonical receipt pins its content digest through the same -ART put, independent verification and immutable binding path; later workers may -only recover those exact bytes. An invocation-local opaque handle is minted only -after the ART binding acknowledgement and may be replaced on retry. Handles and -lease identities never enter the paid request hash; the exact verified canonical -receipt bytes and digest do. CHECKERS owns trial reservation/currentness and the -0061 strong owner schema; MODEL-USAGE owns only the bounded resolver/session seam. +Execution-trial reservation, engine behavior and trial-evidence ownership remain +in issue #493. This change exposes the bounded multi-call/model-evidence session +used by that project runtime, but does not claim execution-tool custody or add a +trial table to the independent #492 migration. The local relay root must be mounted at the same Docker-daemon-visible path, with private same-UID socket custody. gVisor requires the administrator-provided @@ -228,11 +220,13 @@ honest separation of local proof from deployed operation. ## Evidence Initial source inspection and architecture review identified the stable paid -slot and the need to preserve image ownership. This is a design record; no -live provider spend or project completion is established yet. The implementation -has focused offline provider, proxy crash/replay, private Unix transport and -Compose lifecycle proof. The retained-data 0058-to-0059 upgrade and PostgreSQL -ledger, signed public provisioning and real MCP catalogue adapter checks pass. +slot and the need to preserve image ownership. No live provider spend or project +completion is established. The candidate has focused controlled-provider, +proxy crash/replay, private Unix transport, setup/checker shared-account and +hosted Compose contract proof. The retained-data upgrade through +`0060_audit_restore_path` to `0061_model_usage`, PostgreSQL ledger, public +provisioning and actual authorization catalogue adapter require final exact-head +evidence before readiness. All six composed public profile-to-checker cases pass with real PostgreSQL, local/MinIO ART and a controlled provider/image boundary: completion, retained paid results after a lost image reply, concurrent callbacks, and explicit child @@ -247,7 +241,7 @@ requires the exact registered POST image digest, with independent SQL checks of canonical profile/budget specification hashes. Regressions cover each boundary; the final frozen review must assess their composed behavior and current head. -The retained-data 0058-to-0059 upgrade exposed a PL/pgSQL parsing failure that +The retained-data upgrade into the model-usage migration exposed a PL/pgSQL parsing failure that fresh baseline installation did not expose. A discriminating PostgreSQL probe isolated the unparenthesized CASE expression in the ART binding guard; the corrected upgrade passes with retained data and owned-database cleanup. Public @@ -266,7 +260,7 @@ That composed path also exposed an inherited ART receipt trigger that required a checker-run producer even for a paid-call receipt. The migration now admits only the exact model-call attempt and request/response role while retaining the existing producer guards. Fresh schema installation, the canonical ART custody -case and retained 0058-to-0059 upgrade pass with the corrected trigger. +case and retained intermediate upgrade pass with the corrected trigger. Canonical storage regressions also require malformed or substituted commitment facts to leave AUTH through its concealed unavailable outcome. Resource @@ -338,7 +332,14 @@ bytes, credentials, list access or mutation authority. The action/permission pair is added to the same linear `0061_model_usage` audit allowlist and the MCP authorization-context snapshot. -## Independent QA, test-delta and CI review +## Historical checker-only review + +The following review is retained as evidence for the earlier checker-only +checkpoint. Its head, paths, migration names and verdict do not qualify the +current shared setup/POST candidate. Current exact-head recovery qualification, +full CI and independent security/authorization/database/QA review remain pending. + +### Independent QA, test-delta and CI review Review dimensions: `qa`, `test_delta`, and `ci_integrity`. Review target: base and merge base `6fa9a9b557fa3ae22c16af67bca25dc868f67b38`, frozen head diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index d7c12e70f..5131ccf22 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -494,9 +494,14 @@ class TestLane: "tests/checkers_model_calls/test_model_call_transactions_postgresql.py", "tests/checkers_model_calls/test_model_invocation_grants_postgresql.py", "tests/checkers_model_calls/test_model_shared_setup_postgresql.py", + "tests/checkers_model_calls/test_model_usage_inspection.py", "tests/checkers_model_calls/test_model_runtime.py", "tests/checkers_model_calls/test_proxy_orchestration.py", "tests/checkers_model_calls/test_public_api.py", + "tests/checkers_model_calls/test_setup_model_handler.py", + "tests/checkers_model_calls/test_setup_model_handler_postgresql.py", + "tests/checkers_model_calls/test_setup_model_proxy_transport.py", + "tests/checkers_model_calls/test_setup_output_custody.py", "tests/checkers/model_provider/test_openai_responses.py", "tests/checkers/external/test_model_transport.py", "tests/checkers/external/test_model_proxy_transport.py", diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index 04e7d4bf8..979b210d9 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -70,9 +70,14 @@ def test_checker_delivery_and_routing_preparation_partition_every_node_once() -> "tests/checkers_model_calls/test_model_call_transactions_postgresql.py", "tests/checkers_model_calls/test_model_invocation_grants_postgresql.py", "tests/checkers_model_calls/test_model_shared_setup_postgresql.py", + "tests/checkers_model_calls/test_model_usage_inspection.py", "tests/checkers_model_calls/test_model_runtime.py", "tests/checkers_model_calls/test_proxy_orchestration.py", "tests/checkers_model_calls/test_public_api.py", + "tests/checkers_model_calls/test_setup_model_handler.py", + "tests/checkers_model_calls/test_setup_model_handler_postgresql.py", + "tests/checkers_model_calls/test_setup_model_proxy_transport.py", + "tests/checkers_model_calls/test_setup_output_custody.py", "tests/checkers/model_provider/test_openai_responses.py", "tests/checkers/external/test_model_transport.py", "tests/checkers/external/test_model_proxy_transport.py", @@ -283,8 +288,8 @@ def test_semantic_family_membership_has_an_independent_fingerprint() -> None: "c8a0538c251f77fc1893ed8aa76feff8fa2134cf8e9b5a049f32fbabd5226277", ), "project": ( - 224, - "af2085eeeeb3f8efce01b9db66ff9d2b6576bf0fb9de9c182a19914fa0507042", + 229, + "5b644de8f6d38d47c7bcd7f048a3497178597dc610f8da5bbe67fccd570a269a", ), "task": ( 95, @@ -356,9 +361,9 @@ def test_project_lane_ownership_has_an_independent_fingerprint() -> None: ) + "\n" ) - assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 224 + assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 229 assert hashlib.sha256(canonical.encode()).hexdigest() == ( - "aef9cc55c87d95b2ada9ddda594aea06a7aebc7035205710ecb7083256152a25" + "972af04cc7a86c871d97fe4b059a870e0849d9586a67eb8b523c910968f88c8a" ) diff --git a/docs/operations_checker_models.md b/docs/operations_checker_models.md index db96ae95e..a327a39ae 100644 --- a/docs/operations_checker_models.md +++ b/docs/operations_checker_models.md @@ -1,155 +1,183 @@ -# Project checker model calls +# Shared setup and POST model access -Model review is part of a project's external POST image. The image owns its -prompt, rubric and interpretation. Workstream controls which model it can call, -the maximum spend, private evidence and recovery. The shared Rust runner only -provides the invocation's private socket; it contains no project rubric or -provider SDK. PRE images have no model capability. +Workstream pays for supported model calls through one project-lifetime account. +The same account serves guide setup inference and approved POST checker agents, +but each call keeps a strong workload-specific owner: -## Configure a project +- setup calls bind the exact compilation attempt, setup generation, runtime + configuration hash and phase/call index; +- POST calls bind the exact Submission, requirement, locked registry image and + configuration, current checker execution and stable paid slot. -Register the project's digest-pinned POST image through the existing checker -registry. An Operator provisions an immutable model profile for that exact -project and registry entry, and a finite lifetime project budget. The profile -contains the provider/API pair, exact model snapshot, reasoning and verbosity, -byte/token/deadline bounds, and integer pricing with an immutable pricing -reference. The supported pair is `openai_responses` / `responses.v1`. The adapter -explicitly requests the provider's standard pricing tier. Verify the selected -snapshot's current standard rates before authorizing live spend. +The model proxy owns credentials, request bounds, reservations, provider effects, +private evidence and settlement. It does not own setup tools, checker rubrics, +submitted-code execution, policy approval, acceptance or revision routing. -Include the complete selector in the existing policy proposal's image -configuration: +## Provision a project + +A system Operator publishes immutable project model profiles and configures one +finite lifetime budget. These are separate authorized operations; approving a +guide or POST policy never grants budget authority. + +| Operation | Route | +| --- | --- | +| Publish an immutable profile | `POST /api/v1/operations/projects/{project_id}/model-profiles` | +| Configure the project lifetime ceiling | `POST /api/v1/operations/projects/{project_id}/model-budget-configurations` | +| Inspect one exact paid effect | `GET /api/v1/operations/projects/{project_id}/model-usage/effects/{effect_id}` | +| Authorize an eligible checker recovery child | `POST /api/v1/operations/projects/{project_id}/model-call-attempts/{parent_attempt_id}/recovery-children` | + +Mutations require their documented idempotency key and fresh system-Operator +authority. The exact-effect read uses `project.model_usage.read` under the +existing model-budget management permission. It conceals missing and foreign +effects and returns only bounded identities, usage, cost and ART custody facts; +it never returns provider IDs, credentials or transcript bytes. + +Amounts are integer USD nanodollars. US$10 is `10000000000`. A new explicitly +authorized budget version may raise the ceiling, but it never resets settled or +reserved spend. `externally_reconciled_prior_spend_nanodollars` records only +spend reconciled outside this ledger and cannot decrease. Historical setup spend +must not be guessed as zero. Until the Operator has reconciled the remaining +allowance, paid calls fail closed. + +Profiles are project-owned, not checker-image-owned. Each immutable version fixes +the provider/API/model snapshot, reasoning and verbosity, per-call and aggregate +call/token/byte/deadline limits, allowed tools and their aggregate limits, integer +pricing ceilings and pricing provenance. Multiple provider/model identities may +coexist for one project. + +Setup selects the newest published version matching the already configured exact +provider/API/model when a compilation attempt is created, then snapshots the +profile ID/version/digest into that attempt. A later publication cannot redirect +an existing attempt. POST policy configuration carries the same selector triple: ```json { - "model_profile_id": "", + "model_profile_id": "", "model_profile_version": 1, - "model_profile_digest": "sha256:" + "model_profile_digest": "sha256:" } ``` -Project-specific configuration may accompany it when the registered image schema -allows those fields. The PM approves this configuration through the existing -policy path. A partial selector, a profile from another project/image, or a PRE -binding is invalid. The setup specification identifies `model_service` with -`model_proxy` provisioning; this does not waive registration, approval or -activation checks. Required unbound capabilities still block activation. - -Budget amounts use integer USD nanodollars: US$10 is `10000000000`. A later budget -configuration changes the ceiling without resetting settled spend or outstanding -reservations. A project cannot lower its ceiling below the sum already committed. -The budget belongs to Workstream-paid evaluation, not contributor compensation. - -The public Operator commands all require a fresh authenticated system Operator -grant. Profile and budget publication also require an `Idempotency-Key`: - -| Command | Route | -| --- | --- | -| Publish an immutable image-bound profile | `POST /api/v1/operations/projects/{project_id}/model-profiles` | -| Configure the lifetime project ceiling | `POST /api/v1/operations/projects/{project_id}/model-budget-configurations` | -| Authorize a permitted child of a terminal call | `POST /api/v1/operations/projects/{project_id}/model-call-attempts/{parent_attempt_id}/recovery-children` | - -Recovery-child replay is naturally identified by the immutable parent and exact -candidate, so that route does not require or use an `Idempotency-Key`. - -`GET /api/v1/operations/projects/{project_id}/checker-retry-sources` includes -bounded `model_recovery_candidates` on the matching terminal source run. Each -candidate contains the exact parent, paid slot, registered image, request hash -and terminal digest. Put `parent_attempt_id` in the child route's path and send -the remaining four fields (`slot`, `registry_entry_id`, `request_sha256`, -`terminal_digest`) as its JSON body. After child authorization, use the ordinary -checker retry command to dispatch a new checker execution. - -The request schemas are exposed in OpenAPI. An exact replay uses the original -key and packet; it does not reset spend or bypass current authorization. A new -budget configuration requires its next explicit version. A recovery child also -requires one exact candidate returned by the existing authorized checker-retry -source read; it cannot replace an uncertain call. Child preparation locks the -candidate's current Task, Assignment and Submission custody without requiring an -Operator to race an execution lease. A later checker invocation must still pass the -ordinary current-run, exact-binding and live-lease checks before dispatch. -Profile provisioning does not approve project policy. +The POST owner separately validates the exact approved registry image and locked +configuration. A partial selector, another project's profile, a same-project +profile with a different provider/model, a PRE binding or a stale policy is +unavailable before spend. + +## Supported provider boundaries + +POST model sessions currently use the closed `openai_responses` / `responses.v1` +adapter. Setup preserves both native agent runtimes through a credential-free +local transport: + +- OpenAI Agents SDK uses the approved `openai` / `responses` request shape; +- OmniCoreAgent uses the approved `openrouter` / `chat_completions` shape. + +The setup proxy accepts only the exact selected model, two neutral document tools, +locked reasoning/verbosity, finite completion output and strict structured output. +Remote prompt references, provider routing/fallback controls, hidden retries, +unapproved service tiers and provider-side storage are rejected. OpenAI input is +counted with the provider's input-count endpoint before dispatch. OpenRouter uses +the exact non-alias model metadata endpoint before dispatch and requires current +context, completion, parameter and pricing metadata to fit the immutable profile; +unknown or unsupported non-token fees fail before spend. Metadata-to-dispatch +consistency remains an external provider assumption that must be requalified for +the selected account and model. + +Setup's fixed `read_document` and `search_document` capability retains its own +owner-side actual call/output counters. A model session opens only when those +runtime ceilings fit inside the selected profile's tool-call and tool-output +ceilings. The profile's aggregate paid-call/token/byte envelope spans the complete +setup attempt across draft, review and correction phases. ## Runtime and credentials -For the local agent-loop compose overlay, enable the `model-proxy` profile and -start its singleton `model-proxy` service with -`WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET=/run/ws-model-proxy/model-proxy.sock`: - -```bash -docker compose -f docker-compose.yml -f docker-compose.agent-loop.yml \ - --profile backend --profile agent-loop --profile model-proxy up -d model-proxy -``` - -Ordinary API processes do not own this listener. The backend's existing -`OPENAI_API_KEY` supplies credentials; never put that key in an image, policy, -checker input or command output. Configure the Rust service's `model_proxy_socket` -to the same path and `model_relay_root` to its private daemon-visible relay root. -The local configuration generator supports `--enable-model-proxy` for those Rust -settings. See the [external checker protocol](../external_checkers/README.md). - -Run exactly one model-proxy process owning a given socket. The listener -requires an existing private directory owned by its process user, refuses an -occupied socket path, and drains admitted handlers during graceful shutdown. -After a forced process kill, confirm the old owner has stopped before removing -its stale socket and restarting. Restart does not clear reservations or replay -provider creates. The proxy's durable tables and private ART objects remain the -recovery authority. - -## What happens for each call - -1. A current POST execution receives one short-lived invocation grant for its - exact locked binding. The grant never enters the image. Rust exposes only - `/work/model.sock` to that image, with ordinary networking still disabled. -2. The image sends one bounded prompt. Workstream authorizes it, binds the exact - request hash to a stable paid-call slot and consumes the invocation grant. - A successor checker run uses the same slot; a changed prompt is a conflict. -3. Workstream retains and verifies the request through ART. In one PostgreSQL - transaction it reserves the profile's maximum possible charge and commits - dispatch intent. Only the caller that first committed that intent may create - the provider response. Network I/O occurs outside the database transaction. -4. The provider input-token count must fit the approved profile. The adapter - disables automatic create retries and requests a stored background response. - It persists a returned provider ID before polling or retaining terminal bytes. -5. Authenticated terminal output and complete usage are retained through ART. - Settlement replaces the reservation once, using the locked integer rates. - Cached input is priced separately; reasoning tokens are part of output tokens - and are not charged twice. A current authorized invocation receives only - bounded output and a receipt digest, never provider coordinates. -6. The image interprets that output and returns the ordinary normalized checker - result. Workstream verifies the corresponding completed call before accepting - a completed model-image result. Existing routing decides acceptance or - contributor revision; the model proxy cannot do either. +Run exactly one trusted model-proxy process for a private socket. Project images +receive only an invocation-local relay socket. A hosted setup worker receives the +global socket directory read-only; it does not own the socket and receives no +provider key. API and Beat receive neither socket nor provider credential. + +Hosted inference uses a dedicated digest-pinned `agents` backend image for the +proxy even when the setup worker uses the mutually exclusive `omnicore` image. +The proxy image contains the pinned OpenAI input-count/Responses dependencies and +the OpenRouter HTTP adapter. Its entrypoint validates the root-owned SDK marker +before reading the two closed proxy-only provider secrets. Proxy enablement is +independent of setup runtime selection, so `manager_authored` setup can coexist +with model-backed POST checks and OpenRouter setup can coexist with OpenAI POST. +See +[`deploy/hosted/README.md`](../deploy/hosted/README.md). + +The local agent-loop overlay uses +`WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET=/run/ws-model-proxy/model-proxy.sock` and +the existing `model-proxy` profile. Configure the Rust checker service with the +same global socket and its private relay root. The image never sees that global +socket, a provider key, model coordinates or arbitrary network egress. See the +[external checker protocol](../external_checkers/README.md). + +Socket roots are canonical mode-`0700` directories and sockets are mode `0600` +under one non-root identity. Stop producers first, drain workers, then drain the +proxy before removing any stale socket. Restarting a process does not clear a +reservation or authorize another provider create. + +## Paid-call lifecycle + +1. The workload owner proves current authority and exact locked lineage before + taking MODEL-USAGE locks. +2. MODEL-USAGE selects the immutable profile/account/run envelope and stable + phase/call slot, verifies the canonical request hash and retains request ART. +3. It reserves that dispatch's worst-case charge and conservative token/response + capacity against both the project lifetime account and immutable run envelope, + then commits dispatch intent before provider I/O. +4. The trusted adapter performs exactly one bounded provider dispatch with hidden + retries disabled. Provider acceptance, when known, is checkpointed with the + provider ID and response digest before response ART work. +5. Canonical response and usage bytes are stored, independently verified and + immutably bound through ART. Settlement replaces the reservation once using + the locked pricing version. Cached input is priced separately and reasoning + tokens are not charged twice. +6. A retry with the same stable owner and request reconstructs the exact retained + response. Request drift conflicts; lease renewal or checker retry does not mint + a new paid effect. + +Each setup phase may consume several finite call slots; POST profiles likewise +bound the complete image session. A conditional image path may leave an unused +capability untouched, but every started call must reach a replay-safe terminal or +remain held as uncertain. No profile, image, correction or retry creates a second +project budget. ## Failure and recovery -| Condition | Retained effect | Permitted next action | +| Condition | Durable result | Safe next action | | --- | --- | --- | -| Budget unavailable | No provider dispatch | Configure sufficient budget, then use existing checker recovery | -| Input counting proves no create occurred | `known_no_effect`; reservation released | Explicit Operator-authorized child attempt, then checker recovery | -| Create response lost, no provider ID | Uncertain; full reservation retained | Investigate; no automatic or Operator child create without proof of no effect | -| Known provider ID still pending/unavailable | Original effect and reservation retained | Retrieve that ID through a fresh authorized execution | -| Terminal failure without complete usage | Uncertain; reservation retained | Recover complete usage for the original ID; no paid child yet | -| Terminal failure with complete settled usage | `known_failed`, immutable evidence | Explicit Operator-authorized child attempt, then checker recovery | -| Completed call but image response lost | Completed effect, original ART bytes and settled cost | Fresh current execution attaches the same cached completion | - -Infrastructure and budget failures never consume a contributor revision. A late -provider response may finish the original paid call after its checker lease has -ended, but cannot deliver output to a stale invocation or create another effect. -Recovery requires current authorization; knowing a row ID is not authority. - -Request/response evidence is private ART content. Ledger rows contain identities, -hashes, pricing and usage, not transcript bytes. Do not put transcripts or provider -coordinates in contributor findings, logs, exports or outbox payloads. +| Missing or exhausted funding | No provider dispatch | Operator configures reconciled funding; retry the same owner workflow | +| Input/metadata/profile refusal | Proven no provider effect | Correct authorized configuration; do not charge a revision | +| Delivery ambiguous without usable provider evidence | `uncertain`, full reservation retained | Inspect the exact effect; never create again without authenticated no-effect proof | +| Provider ID/response known, ART or callback interrupted | Exact effect and response identity retained | Recover and verify original ART/provider evidence, then complete without another create | +| Complete provider failure with authenticated usage | `known_failed`, usage settled | Checker-only authorized child may be prepared when eligibility is proven | +| Completion delivered but worker/image reply lost | Completed effect, ART response and settled cost | Fresh authorized owner consumes the cached completion | + +Setup recovery never fabricates checker lineage. The setup handler reads retained +verified response ART and completes the original setup effect. Provider storage is +disabled; an ID-less genuinely ambiguous response may remain held indefinitely. +The exact Operator read provides visibility but cannot assert no effect, release +money or create a replacement. + +For POST, budget exhaustion, provider outage, unavailable runtime and invalid +provider output remain infrastructure failure on the original Submission. They do +not prepare a revision, start a revision deadline or spend a revision round. +Eligible checker children still require the existing fresh Operator recovery +authorization and a current checker execution. ## Verification boundaries -Provider transport tests use the real SDK with a recorded HTTP transport; they -make no live calls. PostgreSQL tests cover budget serialization, immutable -receipts and invocation consumption. ART tests exercise storage/verification and -failure recovery. Unix socket tests prove framing and lifecycle locally. -Docker development isolation does not qualify gVisor: the mounted Unix socket -requires administrator-enabled `host-uds=open` and a separate real gVisor run. -Record the exact image digest, profile, budget, tested revision, provider usage -and retained effect identities for a live rehearsal; passing unit counts do not -establish the project's complete activation-to-contribution journey. +Repository tests use controlled HTTP/provider transports; they perform no live +paid calls. PostgreSQL tests cover setup/checker concurrency on one account, +canonical lock waits, rollback, profile/budget versioning, replay and settlement. +MinIO tests cover setup request/response custody and crash recovery. Unix-socket, +Rust relay and networkless-container tests cover the restricted transport. +Release composition tests prove that only the proxy receives provider secrets and +that setup workers mount only the socket read-only. + +These checks do not establish the external provider account state, current live +pricing, model quality, deployed gVisor/AWS operation or the Terminal Systems +activation-to-contribution journey. The existing US$10 authorization is one +aggregate rehearsal ceiling, not an allowance per SDK, image, retry or call. diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 3d99596f4..5bc7361a5 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -81,12 +81,16 @@ lifecycle is proven. See the ## Status Vocabulary -The project-image model runtime adds immutable Operator-provisioned model -profiles, a finite lifetime project budget, private ART call evidence and -recovery of the same paid effect across checker retries. Images keep their own -rubrics and receive only an invocation-local socket through the generic Rust -service. This provides the model capability needed by the real Terminal Systems -POST proposal; it does not establish that project's activation, model quality, +The shared model runtime adds immutable Operator-provisioned project profiles, +one finite lifetime project budget, private ART call evidence and recovery of +the same paid effect across setup-worker renewal and checker retries. Existing +OpenAI Agents SDK and OmniCoreAgent setup loops use the same account as approved +POST image sessions while retaining distinct setup-attempt and checker-execution +lineage. Images keep their own rubrics and receive only an invocation-local +socket through the generic Rust service; hosted setup workers connect read-only +to the trusted proxy and receive no provider key. This provides the model +capability needed by the real Terminal Systems POST proposal; it does not +establish that project's activation, model quality, current provider account, gVisor qualification or live contribution journey. See [model runtime operations](operations_checker_models.md) and its [bounded change](../.commitrail/changes/checker-model-runtime.md). @@ -521,11 +525,11 @@ cannot be reused as post-submission review-gate evidence. See the | Lifecycle stage | Intended merged status | What is already proven | What remains before v0.1 | | --- | --- | --- | --- | -| Project-image model evaluation | **Budgeted runtime and public Operator controls** | Exact approved project/image/profile selection; single-use private callback; stable paid-call identity; atomic lifetime reservation; private ART request/response evidence; retrieve-only recovery after uncertain dispatch; once-only settlement | Qualify each project's POST image and model rubric, exact pricing, intended isolation and capped live calls; broader PM spend reads and setup-agent billing remain separate | +| Shared setup and project-image model use | **Budgeted runtime and public Operator controls** | One project-lifetime funded account serves exact setup-attempt and approved POST image lineage; immutable bounded profiles; finite multi-call/run envelopes and setup document-tool caps; private ART request/response evidence; stable paid-effect replay/recovery; once-only settlement; exact redacted Operator effect inspection; hosted composition gives provider credentials only to the trusted proxy | Complete full CI and independent reviews; qualify the external account/current pricing and each project's substantive rubric under intended gVisor/AWS isolation; then perform only the approved aggregate-capped live rehearsal. Repository proof is controlled/offline and not deployment or model-quality evidence | | Identity and actor resolution | **Live foundation** | Flow-token verification; canonical ActorProfile and ActorIdentityLink; human/service separation; lifecycle controls; canonical `/actors/me` self-read with duplicate `/auth/me` removed; suspension denies both self-read and self-update; CLI privileged exact-actor/profile and identity-link reads and explicit separate profile/link lifecycle writes remain distinct from self-service and backend guards | Final end-to-end operational and conformance proof | | Authorization kernel | **Live foundation** | Closed action/permission catalogues; deny-by-default evaluation; grants; exact-project, privacy-safe candidate read now consumed by the CLI; fixed services; rate controls; opaque transaction-bound PREP; atomic decision evidence; CLI scope-filtered administrative grant/catalogue reads and explicit keyed grant issue/revoke consume existing public APIs without local authority decisions | Activate only the remaining owner-proven TASK, checker, REV, and CON boundaries; remove obsolete authority after replacement paths are live | | Project Guide source custody | **Live foundation** | Guide creation declares documents and task examples; public document upload; immutable internal metadata snapshots; draft guide and review/revision policy writes with fresh-authorized exact replay of original receipts; exact run-scoped setup reads and assigned-task locked-original reads; S3-backed originals and isolated agent document inspection; exact document generation through separate manager approvals and public exact-authorized CP07 activation; Project Manager guide list, guide detail, activation readiness dry run and verified original read-back (PILOT-READ-3); the CLI exposes existing administrative active-chain and retained sufficiency/artifact-policy reads, keyed human sufficiency writes, and separate manual artifact-policy draft create/update with backend-owned policy decisions | Public original upload, task examples, Celery setup and separate approvals are connected locally with recorded inference; prove configured live inference and readers in the intended environment; manual drafts are not compiled activation proposals; deployed CLI remains separate | -| Unified Project Guide compilation | **Live setup and manager proposal operations** | Committed original-document readiness dispatches one immutable attempt through Celery; complete result and crash/recovery custody; distinct pre/post proposals; typed per-requirement classification and checker specifications; deterministic sufficiency and submission-artifact-policy projections; immutable authorized setup finalization; public exact manager review, pre-submit approval and manual correction dispatch; automatic deterministic post-policy derivation, public complete policy read, separate approval and shared correction custody. Neutral read/search tools support statically registered OpenAI Agents SDK and OmniCoreAgent adapters in mutually exclusive inference images, while provider-free `manager_authored` compiles a manager-uploaded rules document with no inference or provider resources ([runtime initiative](../.commitrail/initiatives/WS-AGENT-001/OVERVIEW.md), [manager-authored record](../.commitrail/changes/manager-authored-guide-runtime.md)). Capability gaps preserve a warning-ready review package but block activation; retained pre-specification results keep exact hashes and reads. Exact Omni SDK, mutually exclusive release-image, parser-isolation and real-MinIO proofs pass. Both inference adapters pass the same original-guide public API, durable Celery, real-parser and MinIO path; the Omni run stopped at a warning-ready manager draft with no bindings, approval or activation. The Terminal Systems handoff now has a project-owned image for its two PRE rules, with local rule and networkless-container proof. | Retain the exact external provider/account and selected-image AWS evidence; implement the eight Terminal Systems POST rules and their runtime dependencies; register the required images, obtain approved bindings and qualify the broader hosted Flow contribution journey. Original-guide qualification and local checker proof are not deployed operation or full guide-to-contribution proof. | +| Unified Project Guide compilation | **Live setup and manager proposal operations** | Committed original-document readiness dispatches one immutable attempt through Celery; complete result and crash/recovery custody; distinct pre/post proposals; typed per-requirement classification and checker specifications; deterministic sufficiency and submission-artifact-policy projections; immutable authorized setup finalization; public exact manager review, pre-submit approval and manual correction dispatch; automatic deterministic post-policy derivation, public complete policy read, separate approval and shared correction custody. Neutral read/search tools support statically registered OpenAI Agents SDK and OmniCoreAgent adapters in mutually exclusive inference images, while provider-free `manager_authored` compiles a manager-uploaded rules document with no inference or provider resources ([runtime initiative](../.commitrail/initiatives/WS-AGENT-001/OVERVIEW.md), [manager-authored record](../.commitrail/changes/manager-authored-guide-runtime.md)). Inference attempts snapshot an exact immutable model profile and use the shared project-funded proxy; fixed document-tool limits must fit that profile and provider credentials remain outside the worker. Capability gaps preserve a warning-ready review package but block activation; retained pre-specification results keep exact hashes and reads. Exact Omni SDK, mutually exclusive release-image, parser-isolation and real-MinIO proofs pass. Both inference adapters pass the same original-guide public API, durable Celery, real-parser and MinIO path; the Omni run stopped at a warning-ready manager draft with no bindings, approval or activation. The Terminal Systems handoff now has a project-owned image for its two PRE rules, with local rule and networkless-container proof. | Retain the exact external provider/account and selected-image AWS evidence; reconcile remaining aggregate rehearsal allowance before paid setup; implement the eight Terminal Systems POST rules and their runtime dependencies; register the required images, obtain approved bindings and qualify the broader hosted Flow contribution journey. Original-guide qualification and local checker proof are not deployed operation or full guide-to-contribution proof. | | Contribution policy administration | **Public Finance policy workflow; binding administration internal** | Internal Finance Authority adapter-binding lifecycle; public ContributionPolicy discovery/read/create/update/publish/retire with exact Finance Authority and recoverable draft selectors, and a Finance list of every version (PILOT-READ-3); immutable operation and event history; internal exact selected-version validation; CP07 binding with live exact-project manager authority of the selected published version to the active guide generation | The bounded local public drill passed; policy publication alone does not create a contribution, and hosted operation remains unproven | | Task readiness and claim | **Foundation with grant-backed manager and contributor commands** | Task records, assignments and locked work context; guide-bound ContributionPolicyVersion locked before `READY` and copied to TaskAssignment; detached project/guide display; public project-scoped ready, management and operational queues with distinct current grant authority and signed bounded live pagination, with typed task status and manager filters by status, assignee, native import batch and update time (PILOT-READ-2); contributor cross-project own-assignment, attempt and summary reads under current Submitter grants (PILOT-READ-2); Project Manager project-wide attempt, claim, summary and import-batch reads (PILOT-READ-3); public contributor/management detail with exact project and assignment authority; separate current live work-context projections with exact receipt-selected review/revision/ContributionPolicy identities and no obsolete economic fields; explicit management/operational/audit locked-context projections using one historical resolver, with separate public manager, system-Operator and Audit Authority access; immutable contributor/management requirements using one historical translator, with public exact Contributor and Manager authority; the CLI exposes the three exact manager requirements/context reads separately from contributor commands (WS-CLI-001-27); bounded public Audit Authority lifecycle evidence with atomic project/task scoping and exact transition references; separate CLI audit commands consume those two existing Audit Authority GETs with explicit continuation; public manager create/screen/release use exact covered Project Manager authority, atomic audit and replay; claim/start/contributor context use exact-project Submitter grants; assigned context lists documents only and distinct `task.guide.read` verifies locked originals before delivery; separate manager context and system-Operator start; durable claim/start retry receipts with fresh authority and exact assignment checks; hidden exact-assignment invalidation with committed cause verification, delivery fencing, exact fixed-service authority and decision-bound immutable release evidence; atomic authority-loss publication and registered prefork delivery; PILOT-14 exposes both locked contribution types in ready rows and Contributor detail for active exact-project Submitters or Reviewers as unpaid or exact instrument/unit/quantity awards, without Finance binding facts; claim/start remain Submitter-only | The bounded local public durable-runtime journey passed; intended hosted operation remains unproven | | Operator task CLI | **Existing public task operations consumed by the CLI** | WS-CLI-001-36 adds separate status-only project pages, exact-project locked-reference reads and explicit reasoned start override with caller-owned manual replay. Backend Operator authority, assignment consistency, locked lineage and audit remain decisive; contributor start has no fallback to override. | Native public-API proof is bounded to existing fixtures; hosted/deployed operation, other Operator workflows and published binary remain separate | @@ -1297,8 +1301,8 @@ Delivered foundations (bounded local journey proven; no deployment claim) task/assignment/Submission lineage + public ZIP intake/admission-backed creation ARCH-03D exact approved historical guide through durable intake handoff ARCH-04B hidden verified Submission input - private model-call ART request/response custody with exact fixed-service authority - immutable project model profiles + lifetime budget + stable paid-effect recovery + shared setup/POST model-call ART request/response custody with exact owner authority + immutable project model profiles + one lifetime account + stable multi-call paid-effect recovery ARCH-04C hidden durable execution/current results + atomic completion event shared dispatcher + exact-authorized hidden assignment invalidation ARCH-03C2 invalidation producer + first handler registration @@ -1445,10 +1449,12 @@ v0.1 is not ready until all of the following are true: environment; configuration rendering and local source-image builds are only prerequisites. - Qualify model-enabled POST images with their approved profiles, current exact - pricing and finite lifetime project budget. Prove invocation sockets under the - intended isolation and recover interrupted paid effects without another - create. The Terminal Systems live rehearsal has a US$10 aggregate cap; - offline transport/ledger tests do not establish its full project journey. + pricing and the same finite lifetime project budget used by setup inference. + Reconcile any historical setup spend before enabling that budget, prove setup + and invocation sockets under the intended isolation, and recover interrupted + paid effects without another create. The Terminal Systems live rehearsal has + one US$10 aggregate cap across SDKs, images and retries; offline transport, + ART and ledger tests do not establish its full project journey. - Prove artifact diagnosis/retry in the intended hosted S3 environment; local public API/CLI proof does not establish hosted provider operation. - A task cannot enter `READY` without that complete locked context. From 5fc3c6edc22746a55a7ebfd1d446f95e16667ca9 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:18:23 +0100 Subject: [PATCH 038/115] fix(auth): admit setup model usage decisions --- .../app/modules/authorization/domain/audit.py | 2 ++ .../checker_models/test_contracts.py | 29 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/backend/app/modules/authorization/domain/audit.py b/backend/app/modules/authorization/domain/audit.py index ee711294a..a9d72d73f 100644 --- a/backend/app/modules/authorization/domain/audit.py +++ b/backend/app/modules/authorization/domain/audit.py @@ -26,6 +26,7 @@ "checker_history", "checker_model_call", "checker_model_output", + "setup_model_usage", "setup_model_call", "project_model_administration", "artifact_put_attempt", @@ -88,6 +89,7 @@ "checker_history", "checker_model_call", "checker_model_output", + "setup_model_usage", "setup_model_call", "project_model_administration", "actor_profile", diff --git a/backend/tests/authorization/checker_models/test_contracts.py b/backend/tests/authorization/checker_models/test_contracts.py index d131314e0..2af1f0769 100644 --- a/backend/tests/authorization/checker_models/test_contracts.py +++ b/backend/tests/authorization/checker_models/test_contracts.py @@ -6,19 +6,26 @@ from types import SimpleNamespace import pytest -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id from app.modules.actors.api import ServiceIdentity from app.modules.authorization.catalogue import ActionId, PermissionId +from app.modules.authorization.domain.audit import ( + AuthorizationDecisionResourceType, + CONTEXT_DIGEST_RESOURCE_TYPES, +) from app.modules.authorization.model_usage_authorization import CheckerModelAuthorization from app.modules.authorization.domain.model_usage import ( GRANT, RECOVER, + SETUP_PREPARE, ModelCallGrantAuthorityFacts, ModelCallRecoverAuthorityFacts, + SetupModelPrepareAuthorityFacts, checker_model_call_resource, + setup_model_usage_resource, ) from app.modules.authorization.domain.checker_outputs import ( BIND, @@ -185,6 +192,26 @@ def setup_selector(*, role="model_request") -> SetupModelOutputSelector: ) +def test_setup_usage_resource_is_admitted_by_decision_and_audit_vocabularies() -> None: + slot = setup_selector().slot_key + resource = setup_model_usage_resource( + SETUP_PREPARE, + SetupModelPrepareAuthorityFacts( + slot=slot, + request_sha256=sha("setup request"), + ), + ) + + assert resource.resource_type == "setup_model_usage" + assert ( + TypeAdapter(AuthorizationDecisionResourceType).validate_python( + resource.resource_type + ) + == resource.resource_type + ) + assert resource.resource_type in CONTEXT_DIGEST_RESOURCE_TYPES + + @pytest.mark.parametrize("action", [WRITE, BIND]) def test_setup_output_resource_is_closed_to_exact_effect_and_project(action) -> None: selected = setup_selector() From 1f65182da45937326e1c9f23272fdca21c4aec66 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:27:06 +0100 Subject: [PATCH 039/115] test(setup): cover native Omni request settings --- backend/tests/test_omnicore_agent_sdk.py | 3 +++ docs/operations_checker_models.md | 4 ++-- docs/roadmap_status.md | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/tests/test_omnicore_agent_sdk.py b/backend/tests/test_omnicore_agent_sdk.py index 30cffd652..dd3610fbb 100644 --- a/backend/tests/test_omnicore_agent_sdk.py +++ b/backend/tests/test_omnicore_agent_sdk.py @@ -405,6 +405,7 @@ async def _send_guard_request( ], "temperature": 0, "max_tokens": 16_384, + "reasoning_effort": "medium", "tools": _MODEL_TOOLS, "usage": {"include": True}, }, @@ -762,6 +763,8 @@ def release(self, _lease): "usage", "provider", "plugins", + "reasoning_effort", + "verbosity", } assert all(set(body) == expected_fields for _, body in provider.requests) for request_index in (4, 6): diff --git a/docs/operations_checker_models.md b/docs/operations_checker_models.md index a327a39ae..29e8fa4ce 100644 --- a/docs/operations_checker_models.md +++ b/docs/operations_checker_models.md @@ -114,7 +114,7 @@ socket, a provider key, model coordinates or arbitrary network egress. See the [external checker protocol](../external_checkers/README.md). Socket roots are canonical mode-`0700` directories and sockets are mode `0600` -under one non-root identity. Stop producers first, drain workers, then drain the +under one non-root identity. Stop producers first, drain active executions, then drain the proxy before removing any stale socket. Restarting a process does not clear a reservation or authorize another provider create. @@ -153,7 +153,7 @@ project budget. | Delivery ambiguous without usable provider evidence | `uncertain`, full reservation retained | Inspect the exact effect; never create again without authenticated no-effect proof | | Provider ID/response known, ART or callback interrupted | Exact effect and response identity retained | Recover and verify original ART/provider evidence, then complete without another create | | Complete provider failure with authenticated usage | `known_failed`, usage settled | Checker-only authorized child may be prepared when eligibility is proven | -| Completion delivered but worker/image reply lost | Completed effect, ART response and settled cost | Fresh authorized owner consumes the cached completion | +| Completion delivered but runtime/image reply lost | Completed effect, ART response and settled cost | Fresh authorized owner consumes the cached completion | Setup recovery never fabricates checker lineage. The setup handler reads retained verified response ART and completes the original setup effect. Provider storage is diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 5bc7361a5..0988de450 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -529,7 +529,7 @@ cannot be reused as post-submission review-gate evidence. See the | Identity and actor resolution | **Live foundation** | Flow-token verification; canonical ActorProfile and ActorIdentityLink; human/service separation; lifecycle controls; canonical `/actors/me` self-read with duplicate `/auth/me` removed; suspension denies both self-read and self-update; CLI privileged exact-actor/profile and identity-link reads and explicit separate profile/link lifecycle writes remain distinct from self-service and backend guards | Final end-to-end operational and conformance proof | | Authorization kernel | **Live foundation** | Closed action/permission catalogues; deny-by-default evaluation; grants; exact-project, privacy-safe candidate read now consumed by the CLI; fixed services; rate controls; opaque transaction-bound PREP; atomic decision evidence; CLI scope-filtered administrative grant/catalogue reads and explicit keyed grant issue/revoke consume existing public APIs without local authority decisions | Activate only the remaining owner-proven TASK, checker, REV, and CON boundaries; remove obsolete authority after replacement paths are live | | Project Guide source custody | **Live foundation** | Guide creation declares documents and task examples; public document upload; immutable internal metadata snapshots; draft guide and review/revision policy writes with fresh-authorized exact replay of original receipts; exact run-scoped setup reads and assigned-task locked-original reads; S3-backed originals and isolated agent document inspection; exact document generation through separate manager approvals and public exact-authorized CP07 activation; Project Manager guide list, guide detail, activation readiness dry run and verified original read-back (PILOT-READ-3); the CLI exposes existing administrative active-chain and retained sufficiency/artifact-policy reads, keyed human sufficiency writes, and separate manual artifact-policy draft create/update with backend-owned policy decisions | Public original upload, task examples, Celery setup and separate approvals are connected locally with recorded inference; prove configured live inference and readers in the intended environment; manual drafts are not compiled activation proposals; deployed CLI remains separate | -| Unified Project Guide compilation | **Live setup and manager proposal operations** | Committed original-document readiness dispatches one immutable attempt through Celery; complete result and crash/recovery custody; distinct pre/post proposals; typed per-requirement classification and checker specifications; deterministic sufficiency and submission-artifact-policy projections; immutable authorized setup finalization; public exact manager review, pre-submit approval and manual correction dispatch; automatic deterministic post-policy derivation, public complete policy read, separate approval and shared correction custody. Neutral read/search tools support statically registered OpenAI Agents SDK and OmniCoreAgent adapters in mutually exclusive inference images, while provider-free `manager_authored` compiles a manager-uploaded rules document with no inference or provider resources ([runtime initiative](../.commitrail/initiatives/WS-AGENT-001/OVERVIEW.md), [manager-authored record](../.commitrail/changes/manager-authored-guide-runtime.md)). Inference attempts snapshot an exact immutable model profile and use the shared project-funded proxy; fixed document-tool limits must fit that profile and provider credentials remain outside the worker. Capability gaps preserve a warning-ready review package but block activation; retained pre-specification results keep exact hashes and reads. Exact Omni SDK, mutually exclusive release-image, parser-isolation and real-MinIO proofs pass. Both inference adapters pass the same original-guide public API, durable Celery, real-parser and MinIO path; the Omni run stopped at a warning-ready manager draft with no bindings, approval or activation. The Terminal Systems handoff now has a project-owned image for its two PRE rules, with local rule and networkless-container proof. | Retain the exact external provider/account and selected-image AWS evidence; reconcile remaining aggregate rehearsal allowance before paid setup; implement the eight Terminal Systems POST rules and their runtime dependencies; register the required images, obtain approved bindings and qualify the broader hosted Flow contribution journey. Original-guide qualification and local checker proof are not deployed operation or full guide-to-contribution proof. | +| Unified Project Guide compilation | **Live setup and manager proposal operations** | Committed original-document readiness dispatches one immutable attempt through Celery; complete result and crash/recovery custody; distinct pre/post proposals; typed per-requirement classification and checker specifications; deterministic sufficiency and submission-artifact-policy projections; immutable authorized setup finalization; public exact manager review, pre-submit approval and manual correction dispatch; automatic deterministic post-policy derivation, public complete policy read, separate approval and shared correction custody. Neutral read/search tools support statically registered OpenAI Agents SDK and OmniCoreAgent adapters in mutually exclusive inference images, while provider-free `manager_authored` compiles a manager-uploaded rules document with no inference or provider resources ([runtime initiative](../.commitrail/initiatives/WS-AGENT-001/OVERVIEW.md), [manager-authored record](../.commitrail/changes/manager-authored-guide-runtime.md)). Inference attempts snapshot an exact immutable model profile and use the shared project-funded proxy; fixed document-tool limits must fit that profile and provider credentials remain outside the setup process. Capability gaps preserve a warning-ready review package but block activation; retained pre-specification results keep exact hashes and reads. Exact Omni SDK, mutually exclusive release-image, parser-isolation and real-MinIO proofs pass. Both inference adapters pass the same original-guide public API, durable Celery, real-parser and MinIO path; the Omni run stopped at a warning-ready manager draft with no bindings, approval or activation. The Terminal Systems handoff now has a project-owned image for its two PRE rules, with local rule and networkless-container proof. | Retain the exact external provider/account and selected-image AWS evidence; reconcile remaining aggregate rehearsal allowance before paid setup; implement the eight Terminal Systems POST rules and their runtime dependencies; register the required images, obtain approved bindings and qualify the broader hosted Flow contribution journey. Original-guide qualification and local checker proof are not deployed operation or full guide-to-contribution proof. | | Contribution policy administration | **Public Finance policy workflow; binding administration internal** | Internal Finance Authority adapter-binding lifecycle; public ContributionPolicy discovery/read/create/update/publish/retire with exact Finance Authority and recoverable draft selectors, and a Finance list of every version (PILOT-READ-3); immutable operation and event history; internal exact selected-version validation; CP07 binding with live exact-project manager authority of the selected published version to the active guide generation | The bounded local public drill passed; policy publication alone does not create a contribution, and hosted operation remains unproven | | Task readiness and claim | **Foundation with grant-backed manager and contributor commands** | Task records, assignments and locked work context; guide-bound ContributionPolicyVersion locked before `READY` and copied to TaskAssignment; detached project/guide display; public project-scoped ready, management and operational queues with distinct current grant authority and signed bounded live pagination, with typed task status and manager filters by status, assignee, native import batch and update time (PILOT-READ-2); contributor cross-project own-assignment, attempt and summary reads under current Submitter grants (PILOT-READ-2); Project Manager project-wide attempt, claim, summary and import-batch reads (PILOT-READ-3); public contributor/management detail with exact project and assignment authority; separate current live work-context projections with exact receipt-selected review/revision/ContributionPolicy identities and no obsolete economic fields; explicit management/operational/audit locked-context projections using one historical resolver, with separate public manager, system-Operator and Audit Authority access; immutable contributor/management requirements using one historical translator, with public exact Contributor and Manager authority; the CLI exposes the three exact manager requirements/context reads separately from contributor commands (WS-CLI-001-27); bounded public Audit Authority lifecycle evidence with atomic project/task scoping and exact transition references; separate CLI audit commands consume those two existing Audit Authority GETs with explicit continuation; public manager create/screen/release use exact covered Project Manager authority, atomic audit and replay; claim/start/contributor context use exact-project Submitter grants; assigned context lists documents only and distinct `task.guide.read` verifies locked originals before delivery; separate manager context and system-Operator start; durable claim/start retry receipts with fresh authority and exact assignment checks; hidden exact-assignment invalidation with committed cause verification, delivery fencing, exact fixed-service authority and decision-bound immutable release evidence; atomic authority-loss publication and registered prefork delivery; PILOT-14 exposes both locked contribution types in ready rows and Contributor detail for active exact-project Submitters or Reviewers as unpaid or exact instrument/unit/quantity awards, without Finance binding facts; claim/start remain Submitter-only | The bounded local public durable-runtime journey passed; intended hosted operation remains unproven | | Operator task CLI | **Existing public task operations consumed by the CLI** | WS-CLI-001-36 adds separate status-only project pages, exact-project locked-reference reads and explicit reasoned start override with caller-owned manual replay. Backend Operator authority, assignment consistency, locked lineage and audit remain decisive; contributor start has no fallback to override. | Native public-API proof is bounded to existing fixtures; hosted/deployed operation, other Operator workflows and published binary remain separate | From 8549f54058c88a4fd9cb55883029792ffbb97cb9 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:27:15 +0100 Subject: [PATCH 040/115] test(mcp): use current permission catalogue in adapter fixtures --- mcp_server/tests/test_access_reads.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcp_server/tests/test_access_reads.py b/mcp_server/tests/test_access_reads.py index ec1e524d3..d05248aa2 100644 --- a/mcp_server/tests/test_access_reads.py +++ b/mcp_server/tests/test_access_reads.py @@ -52,7 +52,7 @@ def _arguments(name: str) -> dict[str, Any]: def _response(name: str) -> dict[str, Any]: # Representative entries use the backend's frozen catalogue totals. if name == "workstream_permissions_list": - return {"items": [{"permission_id": "actor.profile.read_any"}], "total": 91} + return {"items": [{"permission_id": "actor.profile.read_any"}], "total": 95} if name == "workstream_admin_roles_list": return { "items": [ @@ -219,7 +219,7 @@ def test_permission_catalogue_drift_still_fails_closed( {"permission_id": "task.revision.expire"}, {"permission_id": "task.revision.recover"}, ], - "total": 91, + "total": 95, } if malformation == "unknown_permission": payload["items"][0]["permission_id"] = "task.revision.unknown" From 48e30bc424090e8377e98f063f18ae49a58012c4 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:27:25 +0100 Subject: [PATCH 041/115] test(setup): align output scratch capacity --- .../test_setup_model_handler_postgresql.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py index 0853b0b21..e78b6f852 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py +++ b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py @@ -120,6 +120,22 @@ def _setup_profile(project_id: UUID) -> ProjectModelProfileSpec: return ProjectModelProfileSpec.model_validate(source) +def _setup_output_preparation_limits(): + return replace( + artifact_preparation_limits(), + maximum_source_bytes=64 * 1024, + ) + + +def test_setup_profile_fits_output_preparation_capacity() -> None: + profile = _setup_profile(new_record_id()) + limits = _setup_output_preparation_limits() + + assert max(profile.maximum_request_bytes, profile.maximum_response_bytes) <= ( + limits.maximum_source_bytes + ) + + async def _publish_profile(access, project_id: UUID) -> ProjectModelProfile: await access.signed.grant(access.admin, access.target, role="operator") spec = _setup_profile(project_id) @@ -325,7 +341,7 @@ async def _storage_case(tmp_path, database_url, monkeypatch) -> AsyncIterator[_S ) manager = ArtifactScratchManager( root=tmp_path / "setup-model-preparation", - limits=artifact_preparation_limits(), + limits=_setup_output_preparation_limits(), ) engine = create_async_engine(database_url) factory = async_sessionmaker(engine, expire_on_commit=False) From b01771549f529e5bd103916cfced82e3848e5150 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:28:03 +0100 Subject: [PATCH 042/115] docs(model-usage): document proxy custody operations --- backend/app/adapters/model_usage/__init__.py | 7 ++ .../project_agents/setup_model_handler.py | 77 +++++++++++-------- .../project_agents/setup_model_proxy.py | 24 +++++- backend/app/interfaces/setup_model_proxy.py | 55 +++++++++---- backend/app/modules/model_usage/inspection.py | 7 ++ .../app/modules/model_usage/owner_ports.py | 8 +- 6 files changed, 126 insertions(+), 52 deletions(-) diff --git a/backend/app/adapters/model_usage/__init__.py b/backend/app/adapters/model_usage/__init__.py index f076bbcb1..606936cca 100644 --- a/backend/app/adapters/model_usage/__init__.py +++ b/backend/app/adapters/model_usage/__init__.py @@ -2,18 +2,21 @@ def model_profile_reader(session): + """Compose the immutable project-profile reader for an active transaction.""" from app.modules.model_usage.profiles import ModelProfileReader return ModelProfileReader(session) def model_usage_inspection(session, *, authority): + """Compose the exact-effect inspection service with its AUTH port.""" from app.modules.model_usage.inspection import ModelUsageInspectionService return ModelUsageInspectionService(session, authority) def model_call_service(session, *, authority, current_execution, evidence): + """Compose checker-owned paid-call operations over the shared ledger.""" from app.modules.model_usage.service import ModelCallService return ModelCallService( @@ -25,6 +28,7 @@ def model_call_service(session, *, authority, current_execution, evidence): def model_invocation_grants(session, *, authority, current_execution): + """Compose invocation-grant custody for a current checker execution.""" from app.modules.model_usage.grants import ModelInvocationGrants return ModelInvocationGrants( @@ -35,18 +39,21 @@ def model_invocation_grants(session, *, authority, current_execution): def model_evidence_custody(session): + """Compose the checker evidence resolver over stable paid effects.""" from app.modules.model_usage.owner_ports import ModelEvidenceCustody return ModelEvidenceCustody(session) def model_recovery_sources(session): + """Compose retained checker recovery-source projection.""" from app.modules.model_usage.owner_ports import ModelRecoverySources return ModelRecoverySources(session) def setup_model_usage_service(session, *, authority, evidence, owner): + """Compose setup-owned paid-call operations with injected owner custody.""" from app.modules.model_usage.setup import SetupModelUsageService return SetupModelUsageService( diff --git a/backend/app/adapters/project_agents/setup_model_handler.py b/backend/app/adapters/project_agents/setup_model_handler.py index a0bd5f8a5..d279e1318 100644 --- a/backend/app/adapters/project_agents/setup_model_handler.py +++ b/backend/app/adapters/project_agents/setup_model_handler.py @@ -109,6 +109,7 @@ class SetupProviderUncertain(RuntimeError): """A provider dispatch may have created a paid effect.""" def __init__(self, digest: str, provider_response_id: str | None = None) -> None: + """Retain a safe uncertainty digest and any authenticated provider ID.""" super().__init__("setup provider outcome is uncertain") self.digest = digest self.provider_response_id = provider_response_id @@ -117,15 +118,20 @@ def __init__(self, digest: str, provider_response_id: str | None = None) -> None class SetupProvider(Protocol): """Closed provider boundary used by the trusted setup model handler.""" - async def count_input(self, body: dict[str, object]) -> int: ... + async def count_input(self, body: dict[str, object]) -> int: + """Return a conservative provider-qualified input-token count.""" + ... - async def dispatch(self, body_json: str) -> SetupProviderResponse: ... + async def dispatch(self, body_json: str) -> SetupProviderResponse: + """Send one bounded request without an automatic paid retry.""" + ... class OpenAISetupProvider: """No-retry OpenAI Responses adapter owned only by the trusted model process.""" def __init__(self, *, api_key: str, profile: ProjectModelProfile) -> None: + """Bind no-retry clients to one approved OpenAI profile.""" import httpx from openai import AsyncOpenAI @@ -202,6 +208,7 @@ async def dispatch(self, body_json: str) -> SetupProviderResponse: ) from exc async def aclose(self) -> None: + """Close both provider clients and their connection pools.""" await self._counter.close() await self._client.aclose() @@ -210,15 +217,14 @@ class OpenRouterSetupProvider: """No-retry OpenRouter adapter reserving its full pinned model context.""" def __init__(self, *, api_key: str, profile: ProjectModelProfile) -> None: + """Bind one no-retry client to an exact direct OpenRouter model.""" import httpx model_parts = profile.spec.model_snapshot.split("/") if len(model_parts) != 2 or not all(model_parts): raise ModelCallUnavailable("model_profile_provider_unavailable") self._profile = profile - self._model_url = _OPENROUTER_MODEL_URL.format( - author=model_parts[0], slug=model_parts[1] - ) + self._model_url = _OPENROUTER_MODEL_URL.format(author=model_parts[0], slug=model_parts[1]) self._client = httpx.AsyncClient( trust_env=False, follow_redirects=False, @@ -246,6 +252,7 @@ async def count_input(self, _body: dict[str, object]) -> int: return self._profile.spec.maximum_input_tokens async def dispatch(self, body_json: str) -> SetupProviderResponse: + """Send one bounded Chat Completions request and classify uncertainty.""" raw = b"" try: async with self._client.stream( @@ -291,6 +298,7 @@ async def dispatch(self, body_json: str) -> SetupProviderResponse: ) from exc async def aclose(self) -> None: + """Close the provider metadata and dispatch connection pool.""" await self._client.aclose() @@ -306,10 +314,9 @@ def __init__( profiles, outputs, bindings, - provider: Callable[ - [ProjectModelProfile], AbstractAsyncContextManager[SetupProvider] - ], + provider: Callable[[ProjectModelProfile], AbstractAsyncContextManager[SetupProvider]], ) -> None: + """Inject transaction, authority, ledger, ART and provider factories.""" self._sessions = sessions self._authorization = authorization self._calls = calls @@ -319,6 +326,7 @@ def __init__( self._provider = provider async def handle(self, request: SetupModelProxyRequest) -> SetupModelProxyReply: + """Map owner, budget and request failures to the closed wire outcome.""" try: return await self._handle(request) except ModelCallBudgetUnavailable: @@ -327,6 +335,7 @@ async def handle(self, request: SetupModelProxyRequest) -> SetupModelProxyReply: return _unavailable(request, "model_call_unavailable") async def _handle(self, request: SetupModelProxyRequest) -> SetupModelProxyReply: + """Retain, reserve, dispatch and settle one stable setup call slot.""" subject = request.subject key = SetupModelUsageSlotKey( compilation_attempt_id=subject.compilation_attempt_id, @@ -491,6 +500,7 @@ async def _recover_observed( async def _store( self, prepared: PreparedSetupModelUsageEffect, role: str, data: bytes ) -> ModelCallEvidence: + """Store, verify and bind one exact setup request or response artifact.""" selector = SetupModelOutputSelector( slot_key=prepared.slot_key, model_call_slot_id=prepared.slot_id, @@ -500,6 +510,7 @@ async def _store( ) async def source(): + """Yield the one canonical bounded evidence payload.""" yield data stored = await self._outputs.store_setup_output( @@ -530,6 +541,7 @@ async def _cached( completed: CompletedSetupModelUsageEffect, profile: ProjectModelProfile, ) -> SetupModelProxyReply: + """Read and revalidate the retained response for a completed replay.""" selector = SetupModelOutputSelector( slot_key=completed.effect.slot_key, model_call_slot_id=completed.effect.slot_id, @@ -575,6 +587,7 @@ def _validate_request( request: SetupModelProxyRequest, body: object, ) -> None: + """Validate the complete SDK request against the locked profile.""" subject = request.subject if ( not isinstance(body, dict) @@ -583,8 +596,7 @@ def _validate_request( or profile.spec.allowed_tools != ("read_document", "search_document") or body.get("model") != subject.model or body.get("stream", False) is not False - or len(request.provider_request_json.encode("utf-8")) - > profile.spec.maximum_request_bytes + or len(request.provider_request_json.encode("utf-8")) > profile.spec.maximum_request_bytes or not _safe_tools(body.get("tools")) ): raise ModelCallUnavailable("setup_model_request_invalid") @@ -598,15 +610,10 @@ def _validate_request( or body.get("parallel_tool_calls") is not False or body.get("truncation") not in {None, "disabled"} or body.get("include", []) != [] - or body.get("reasoning") - != {"effort": profile.spec.settings.reasoning_effort} - or not _safe_response_text( - body.get("text"), profile.spec.settings.text_verbosity - ) + or body.get("reasoning") != {"effort": profile.spec.settings.reasoning_effort} + or not _safe_response_text(body.get("text"), profile.spec.settings.text_verbosity) or type(body.get("max_output_tokens")) is not int - or not 1 - <= body["max_output_tokens"] - <= profile.spec.maximum_output_tokens + or not 1 <= body["max_output_tokens"] <= profile.spec.maximum_output_tokens ): raise ModelCallUnavailable("setup_model_request_invalid") elif identity == ("openrouter", "chat_completions"): @@ -631,6 +638,7 @@ def _validate_request( def _safe_tools(value: object) -> bool: + """Accept only the two neutral setup document tools.""" if not isinstance(value, list) or len(value) != 2: return False names: list[str] = [] @@ -647,6 +655,7 @@ def _safe_tools(value: object) -> bool: def _safe_response_text(value: object, verbosity: str) -> bool: + """Validate strict Responses output shape and locked verbosity.""" if not isinstance(value, dict) or set(value) - {"format", "verbosity"}: return False output_format = value.get("format") @@ -661,6 +670,7 @@ def _safe_response_text(value: object, verbosity: str) -> bool: def _safe_chat_response_format(value: object) -> bool: + """Validate the optional strict Chat Completions JSON schema.""" if value is None: return True if not isinstance(value, dict) or set(value) != {"type", "json_schema"}: @@ -676,9 +686,7 @@ def _safe_chat_response_format(value: object) -> bool: ) -def _validate_openrouter_metadata( - profile: ProjectModelProfile, value: object -) -> None: +def _validate_openrouter_metadata(profile: ProjectModelProfile, value: object) -> None: """Fail before spend unless current provider metadata fits the pinned profile.""" if not isinstance(value, dict) or set(value) != {"data"}: raise ModelCallUnavailable("model_profile_provider_unavailable") @@ -699,9 +707,7 @@ def _validate_openrouter_metadata( or type(top_provider.get("context_length")) is not int or top_provider["context_length"] != context_length or type(top_provider.get("max_completion_tokens")) is not int - or not 1 - <= profile.spec.maximum_output_tokens - <= top_provider["max_completion_tokens"] + or not 1 <= profile.spec.maximum_output_tokens <= top_provider["max_completion_tokens"] or not isinstance(supported, list) or any(type(item) is not str for item in supported) or not _OPENROUTER_REQUIRED_PARAMETERS.issubset(supported) @@ -710,9 +716,7 @@ def _validate_openrouter_metadata( _validate_openrouter_pricing(profile, data.get("pricing")) -def _validate_openrouter_pricing( - profile: ProjectModelProfile, value: object -) -> None: +def _validate_openrouter_pricing(profile: ProjectModelProfile, value: object) -> None: """Require pinned rates to cover every advertised tier and cache class.""" if not isinstance(value, dict): raise ModelCallUnavailable("model_profile_provider_unavailable") @@ -761,6 +765,7 @@ def _require_zero_unsupported_fees(row: dict) -> None: def _nanodollars(value: object) -> int: + """Round one nonnegative per-token USD string up to nanodollars.""" if type(value) is not str: raise ModelCallUnavailable("model_profile_provider_unavailable") try: @@ -769,9 +774,7 @@ def _nanodollars(value: object) -> int: raise ModelCallUnavailable("model_profile_provider_unavailable") from None if not amount.is_finite() or amount < 0: raise ModelCallUnavailable("model_profile_provider_unavailable") - converted = (amount * Decimal(1_000_000_000)).to_integral_value( - rounding=ROUND_CEILING - ) + converted = (amount * Decimal(1_000_000_000)).to_integral_value(rounding=ROUND_CEILING) if converted > 9_000_000_000_000_000_000: raise ModelCallUnavailable("model_profile_provider_unavailable") return int(converted) @@ -780,6 +783,7 @@ def _nanodollars(value: object) -> int: def _openai_response( profile: ProjectModelProfile, status_code: int, value: dict[str, object] ) -> SetupProviderResponse: + """Normalize an exact OpenAI response, usage and output-byte decision.""" provider_id = value.get("id") provider_model = value.get("model") if ( @@ -801,6 +805,7 @@ def _openai_response( def _openrouter_response( profile: ProjectModelProfile, status_code: int, value: dict[str, object] ) -> SetupProviderResponse: + """Normalize an exact OpenRouter response, usage and output-byte decision.""" provider_id = value.get("id") provider_model = value.get("model") if ( @@ -819,9 +824,7 @@ def _openrouter_response( ) -def _retained_provider_response( - profile: ProjectModelProfile, data: bytes -) -> SetupProviderResponse: +def _retained_provider_response(profile: ProjectModelProfile, data: bytes) -> SetupProviderResponse: """Re-validate exact retained ART bytes without another provider request.""" try: value = json.loads(data, parse_constant=_reject_constant) @@ -838,6 +841,7 @@ def _retained_provider_response( def _usage(value: object) -> ModelCallUsage: + """Decode complete Responses usage including cached and reasoning tokens.""" if not isinstance(value, dict): raise TypeError("provider usage is unavailable") input_details = value.get("input_tokens_details") or {} @@ -851,6 +855,7 @@ def _usage(value: object) -> ModelCallUsage: def _chat_usage(value: object) -> ModelCallUsage: + """Decode complete Chat Completions usage into the shared ledger shape.""" if not isinstance(value, dict): raise TypeError("provider usage is unavailable") input_details = value.get("prompt_tokens_details") or {} @@ -884,6 +889,7 @@ def _model_output_bytes(value: dict[str, object]) -> int: def _chat_output_bytes(value: dict[str, object]) -> int: + """Count model-authored UTF-8 bytes across all completion choices.""" choices = value.get("choices") if not isinstance(choices, list): raise TypeError("provider output is invalid") @@ -891,6 +897,7 @@ def _chat_output_bytes(value: dict[str, object]) -> int: def _generated_string_bytes(root: object) -> int: + """Count bounded content, refusal and tool-argument strings recursively.""" total = 0 stack = [root] while stack: @@ -907,6 +914,7 @@ def _generated_string_bytes(root: object) -> int: def _canonical_bytes(value: object) -> bytes: + """Serialize provider evidence to the canonical retained JSON form.""" return json.dumps( value, sort_keys=True, @@ -929,14 +937,17 @@ async def _bounded_response_bytes(response, maximum_bytes: int) -> bytes: def _sha(data: bytes) -> str: + """Return the canonical prefixed digest for retained bytes.""" return "sha256:" + hashlib.sha256(data).hexdigest() def _reject_constant(_: str) -> None: + """Reject non-finite JSON numeric constants.""" raise ValueError("non-finite provider number") def _unavailable(request: SetupModelProxyRequest, code: str) -> SetupModelProxyReply: + """Build the closed bounded unavailable reply for one call index.""" return SetupModelProxyReply( outcome="unavailable", call_index=request.call_index, diff --git a/backend/app/adapters/project_agents/setup_model_proxy.py b/backend/app/adapters/project_agents/setup_model_proxy.py index 147e1537d..502cbe14f 100644 --- a/backend/app/adapters/project_agents/setup_model_proxy.py +++ b/backend/app/adapters/project_agents/setup_model_proxy.py @@ -44,6 +44,7 @@ def __init__( text_verbosity: str, timeout_seconds: float, ) -> None: + """Validate and bind one attempt's immutable subject and session limits.""" if ( not isinstance(socket_path, Path) or type(subject) is not SetupModelProxySubject @@ -51,8 +52,7 @@ def __init__( or not 1 <= maximum_calls <= 100 or type(maximum_output_tokens) is not int or not 1 <= maximum_output_tokens <= 10_000_000 - or reasoning_effort - not in {"none", "minimal", "low", "medium", "high", "xhigh", "max"} + or reasoning_effort not in {"none", "minimal", "low", "medium", "high", "xhigh", "max"} or text_verbosity not in {"low", "medium", "high"} or type(timeout_seconds) is not float or not 0.1 <= timeout_seconds <= 7_200.0 @@ -71,14 +71,17 @@ def __init__( @property def maximum_output_tokens(self) -> int: + """Return the locked per-dispatch completion-token ceiling.""" return self._maximum_output_tokens @property def reasoning_effort(self) -> str: + """Return the locked provider reasoning setting.""" return self._reasoning_effort @property def text_verbosity(self) -> str: + """Return the locked provider text-verbosity setting.""" return self._text_verbosity async def dispatch( @@ -116,6 +119,7 @@ async def dispatch( return reply async def _round_trip(self, request: SetupModelProxyRequest) -> SetupModelProxyReply: + """Exchange one bounded frame over the exact private Unix socket.""" identity = _validated_socket(self._socket_path) reader: asyncio.StreamReader | None = None writer: asyncio.StreamWriter | None = None @@ -142,7 +146,13 @@ async def _round_trip(self, request: SetupModelProxyRequest) -> SetupModelProxyR if await reader.read(1): raise SetupModelProxyUnavailable("setup model reply has trailing bytes") return _decode_reply(payload) - except (OSError, TimeoutError, asyncio.IncompleteReadError, ValueError, UnicodeError) as exc: + except ( + OSError, + TimeoutError, + asyncio.IncompleteReadError, + ValueError, + UnicodeError, + ) as exc: raise SetupModelProxyUnavailable("setup model proxy is unavailable") from exc finally: if writer is not None: @@ -161,6 +171,7 @@ async def open_session( subject: SetupModelProxySubject | None, configuration: ProjectGuideRuntimeConfiguration, ): + """Yield an exact profile-bound model session or no capability.""" if not configuration.uses_inference: if subject is not None: raise SetupModelProxyUnavailable("setup model subject is unexpected") @@ -236,6 +247,7 @@ def _profile_covers_tool_runtime(profile, configuration) -> bool: def _decode_reply(payload: bytes) -> SetupModelProxyReply: + """Decode the closed trusted-listener response frame.""" value = json.loads( payload.decode("utf-8"), parse_constant=_reject_constant, @@ -272,6 +284,7 @@ def _decode_reply(payload: bytes) -> SetupModelProxyReply: def _validated_socket(path: Path) -> tuple[int, int]: + """Verify exact same-identity socket custody and return its stable inode.""" encoded = os.fsencode(path) if not path.is_absolute() or not encoded or b"\0" in encoded or len(encoded) >= 108: raise SetupModelProxyUnavailable("setup model proxy path is invalid") @@ -288,6 +301,7 @@ def _validated_socket(path: Path) -> tuple[int, int]: def _closed_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Decode one JSON object while rejecting duplicate members.""" value: dict[str, Any] = {} for key, item in pairs: if key in value: @@ -297,6 +311,7 @@ def _closed_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: def _reject_constant(_: str) -> None: + """Reject non-finite numbers in private transport JSON.""" raise ValueError("non-finite setup model proxy number") @@ -327,7 +342,10 @@ def create_setup_httpx_client( raise ValueError("setup model API is unsupported") class DispatchTransport(httpx2.AsyncBaseTransport): + """Translate one closed native SDK request into the setup session.""" + async def handle_async_request(self, request): + """Validate the SDK endpoint and return the trusted proxy reply.""" if ( request.method != "POST" or request.url.host != "model-proxy.invalid" diff --git a/backend/app/interfaces/setup_model_proxy.py b/backend/app/interfaces/setup_model_proxy.py index e0f4adbca..3ce772a4d 100644 --- a/backend/app/interfaces/setup_model_proxy.py +++ b/backend/app/interfaces/setup_model_proxy.py @@ -49,15 +49,19 @@ class SetupModelProxySubject: model: str def __post_init__(self) -> None: + """Reject incomplete, substituted or non-canonical setup lineage.""" if ( - any(type(value) is not UUID for value in ( - self.compilation_attempt_id, - self.project_id, - self.guide_id, - self.source_snapshot_id, - self.setup_run_id, - self.profile_id, - )) + any( + type(value) is not UUID + for value in ( + self.compilation_attempt_id, + self.project_id, + self.guide_id, + self.source_snapshot_id, + self.setup_run_id, + self.profile_id, + ) + ) or type(self.setup_generation) is not int or self.setup_generation < 1 or type(self.profile_version) is not int @@ -80,6 +84,7 @@ class SetupModelProxyRequest: provider_request_json: str def __post_init__(self) -> None: + """Require a canonical provider request and bounded stable call index.""" if ( type(self.subject) is not SetupModelProxySubject or self.phase not in {"draft", "review", "correction"} @@ -92,7 +97,10 @@ def __post_init__(self) -> None: value = json.loads(self.provider_request_json) except (TypeError, ValueError, UnicodeError): raise ValueError("setup model proxy request is invalid") from None - if not isinstance(value, dict) or canonical_provider_json(value) != self.provider_request_json: + if ( + not isinstance(value, dict) + or canonical_provider_json(value) != self.provider_request_json + ): raise ValueError("setup model proxy request is invalid") @property @@ -101,6 +109,7 @@ def request_sha256(self) -> str: return "sha256:" + hashlib.sha256(self.provider_request_json.encode("utf-8")).hexdigest() def __repr__(self) -> str: + """Hide prompt and provider payload content from diagnostic output.""" return "SetupModelProxyRequest()" def wire_value(self) -> dict[str, object]: @@ -131,6 +140,7 @@ class SetupModelProxyReply: session_exhausted: bool def __post_init__(self) -> None: + """Validate completed or unavailable wire state and its frame bound.""" completed = ( self.outcome == "completed" and type(self.status_code) is int @@ -170,9 +180,11 @@ def __post_init__(self) -> None: raise ValueError("setup model proxy reply exceeds transport bound") def __repr__(self) -> str: + """Expose only the outcome while redacting retained provider content.""" return f"SetupModelProxyReply(outcome={self.outcome!r}, )" def wire_value(self) -> dict[str, object]: + """Return the closed response root used by private framing.""" return { "protocol_version": SETUP_MODEL_PROXY_PROTOCOL, "operation": "dispatch", @@ -190,17 +202,25 @@ class SetupModelDispatchPort(Protocol): """Attempt-bound capability used at each native SDK HTTP boundary.""" @property - def maximum_output_tokens(self) -> int: ... + def maximum_output_tokens(self) -> int: + """Return the immutable completion-token ceiling.""" + ... @property - def reasoning_effort(self) -> str: ... + def reasoning_effort(self) -> str: + """Return the immutable reasoning setting.""" + ... @property - def text_verbosity(self) -> str: ... + def text_verbosity(self) -> str: + """Return the immutable text-verbosity setting.""" + ... async def dispatch( self, phase: SetupModelPhase, provider_request: Mapping[str, object] - ) -> SetupModelProxyReply: ... + ) -> SetupModelProxyReply: + """Dispatch one phase call through the trusted metered boundary.""" + ... SetupModelProxyHandler = Callable[[SetupModelProxyRequest], Awaitable[SetupModelProxyReply]] @@ -262,6 +282,7 @@ def setup_model_proxy_request_from_wire(value: object) -> SetupModelProxyRequest def _valid_sha256(value: object) -> bool: + """Recognize one exact lowercase prefixed SHA-256 digest.""" return ( type(value) is str and len(value) == 71 @@ -271,6 +292,7 @@ def _valid_sha256(value: object) -> bool: def _valid_identifier(value: object) -> bool: + """Recognize one bounded lowercase protocol identifier.""" return ( type(value) is str and 1 <= len(value) <= 64 @@ -285,16 +307,21 @@ def _valid_identifier(value: object) -> bool: def _valid_model(value: object) -> bool: + """Recognize one bounded direct provider model identifier.""" return ( type(value) is str and 1 <= len(value) <= 200 and value[0].isalnum() and value[0].isascii() - and all(character.isascii() and (character.isalnum() or character in "._:/-") for character in value) + and all( + character.isascii() and (character.isalnum() or character in "._:/-") + for character in value + ) ) def _valid_headers(value: tuple[tuple[str, str], ...]) -> bool: + """Accept only sorted bounded retry guidance headers.""" allowed = {"retry-after", "x-ratelimit-reset-requests", "x-ratelimit-reset-tokens"} names = [name for name, _ in value] return ( diff --git a/backend/app/modules/model_usage/inspection.py b/backend/app/modules/model_usage/inspection.py index a2cfbfcb0..cce742752 100644 --- a/backend/app/modules/model_usage/inspection.py +++ b/backend/app/modules/model_usage/inspection.py @@ -26,10 +26,12 @@ def _uuid(value: str) -> UUID: + """Decode a stored canonical record identifier.""" return UUID(value) def _checker_key(row: CheckerModelUsageSlot) -> ModelPaidSlotKey: + """Project one stored checker slot into its public stable owner key.""" return ModelPaidSlotKey( project_id=_uuid(row.project_id), assignment_id=_uuid(row.assignment_id), @@ -46,6 +48,7 @@ def _checker_key(row: CheckerModelUsageSlot) -> ModelPaidSlotKey: def _setup_key(row: SetupModelUsageSlot) -> SetupModelUsageSlotKey: + """Project one stored setup slot into its public stable owner key.""" return SetupModelUsageSlotKey( compilation_attempt_id=_uuid(row.compilation_attempt_id), project_id=_uuid(row.project_id), @@ -63,6 +66,7 @@ def _setup_key(row: SetupModelUsageSlot) -> SetupModelUsageSlotKey: def _optional_evidence(row: ModelUsageEffect, prefix: str) -> ModelCallEvidence | None: + """Return complete bounded ART facts or reject partial retained custody.""" values = { "binding_id": getattr(row, f"{prefix}_binding_id"), "content_id": getattr(row, f"{prefix}_content_id"), @@ -81,6 +85,7 @@ def _optional_evidence(row: ModelUsageEffect, prefix: str) -> ModelCallEvidence def _optional_usage(row: ModelUsageEffect) -> ModelCallUsage | None: + """Return complete provider usage or reject a partial usage tuple.""" values = { "input_tokens": row.input_tokens, "cached_input_tokens": row.cached_input_tokens, @@ -98,10 +103,12 @@ class ModelUsageInspectionService: """Authorize before reading MODEL-USAGE and conceal absent or foreign effects.""" def __init__(self, session: AsyncSession, authority: ModelCallAuthorityPort) -> None: + """Bind one database transaction to the exact-effect authority port.""" self._session = session self._authority = authority async def inspect_effect(self, facts: ModelUsageInspectionFacts) -> ModelUsageEffectInspection: + """Authorize and return one redacted same-project paid effect.""" require_model_call_transaction(self._session) facts = ModelUsageInspectionFacts.model_validate(facts) evidence = await self._authority.authorize_usage_read(facts) diff --git a/backend/app/modules/model_usage/owner_ports.py b/backend/app/modules/model_usage/owner_ports.py index f5ea88a10..879559d05 100644 --- a/backend/app/modules/model_usage/owner_ports.py +++ b/backend/app/modules/model_usage/owner_ports.py @@ -36,9 +36,11 @@ class ModelEvidenceCustody: """Lock and validate exact shared effect custody after owner locks.""" def __init__(self, session: AsyncSession) -> None: + """Bind evidence resolution to one caller-owned transaction.""" self._repo = ModelCallRepository(session) async def resolve_evidence(self, selector: ModelEvidenceSelector) -> ModelEvidenceReservation: + """Lock and verify the exact effect, role, digest and profile byte bound.""" selector = ModelEvidenceSelector.model_validate(selector) await self._repo.lock_envelope_scope(selector.paid_slot) if await self._repo.envelope(selector.paid_slot, lock=True) is None: @@ -76,12 +78,14 @@ class ModelRecoverySources: """Project retained checker recovery lineage without owner authorization.""" def __init__(self, session: AsyncSession) -> None: + """Bind retained recovery reads to one caller-owned transaction.""" self._session = session self._repo = ModelCallRepository(session) async def recovery_owner( self, slot: ModelPaidSlotKey, parent_attempt_id: UUID ) -> ModelRecoveryOwnerFacts: + """Resolve the immutable checker owner for one eligible parent effect.""" slot = ModelPaidSlotKey.model_validate(slot) slot_row = await self._repo.slot(slot) parent = await self._repo.attempt(parent_attempt_id) @@ -119,6 +123,7 @@ async def recovery_owner( async def recovery_candidates( self, source_run_ids: tuple[UUID, ...] ) -> dict[UUID, tuple[ModelRecoveryCandidate, ...]]: + """Project terminal effects that may receive an authorized child.""" if not source_run_ids: return {} child = aliased(ModelUsageEffect) @@ -132,8 +137,7 @@ async def recovery_candidates( ) .join( CheckerModelInvocationGrantCall, - CheckerModelInvocationGrantCall.grant_id - == CheckerModelInvocationGrant.id, + CheckerModelInvocationGrantCall.grant_id == CheckerModelInvocationGrant.id, ) .join( ModelUsageEffect, From fa0d2f30db4db1f91d172f1f8c0397db001f791a Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:30:10 +0100 Subject: [PATCH 043/115] test(model-usage): scope fixtures without shadowing suite conftest --- backend/tests/checkers_model_calls/__init__.py | 1 + cli/tests/integration/test_checker_operations_http.py | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 backend/tests/checkers_model_calls/__init__.py diff --git a/backend/tests/checkers_model_calls/__init__.py b/backend/tests/checkers_model_calls/__init__.py new file mode 100644 index 000000000..0316fdc26 --- /dev/null +++ b/backend/tests/checkers_model_calls/__init__.py @@ -0,0 +1 @@ +"""Model-call owner and adapter integration tests with scoped pytest fixtures.""" diff --git a/cli/tests/integration/test_checker_operations_http.py b/cli/tests/integration/test_checker_operations_http.py index 6356ce00f..eece914d4 100644 --- a/cli/tests/integration/test_checker_operations_http.py +++ b/cli/tests/integration/test_checker_operations_http.py @@ -474,12 +474,16 @@ def test_retry_source_page_and_exact_reason_bound_receipt(cli): ("slot_index", True), ("requirement_id", "invalid space"), ): - invalid_candidates.append([candidate | {"slot": candidate["slot"] | {key: value}}]) + invalid_candidates.append( + [candidate | {"slot": candidate["slot"] | {key: value}}] + ) for candidates in invalid_candidates: invalid_page = deepcopy(model_page) invalid_page["items"][0]["model_recovery_candidates"] = candidates response["body"] = json.dumps(invalid_page).encode() - assert_failure(invoke(cli, origin, "retry-sources", PROJECT), "invalid_api_response") + assert_failure( + invoke(cli, origin, "retry-sources", PROJECT), "invalid_api_response" + ) response.update(status=201, body=json.dumps(receipt()).encode()) for _ in range(2): From 0275625778945ef5bc5282d38642289cd1ef8b78 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:35:35 +0100 Subject: [PATCH 044/115] test(setup): expose evidence predicate mismatch --- .../test_setup_model_handler_postgresql.py | 69 ++++++++++++++++++- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py index e78b6f852..aab86271e 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py +++ b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py @@ -385,6 +385,7 @@ class _Journey: reserve_observations: list[tuple[str, str, str]] stages: list[str] failures: list[BaseException] + evidence_diagnostics: list[dict[str, object] | None] fail_completion: list[bool] outputs: object @@ -481,6 +482,58 @@ async def prepare(self, *args, **kwargs): async def reserve_dispatch(self, prepared, request_evidence): self.journey.stages.append("reserve_dispatch") + diagnostic = await self.session.scalar( + text( + "select jsonb_build_object(" + "'binding',jsonb_build_array(binding.resource_type='setup_model_call'," + "binding.resource_id=:effect,binding.logical_role='model_request'," + "binding.scope_version=1,binding.attribution_type='service_identity'," + "binding.actor_id='workstream.artifact.checker_output')," + "'content',jsonb_build_array(binding.content_id=:content," + "content.sha256=:sha,content.byte_count=:bytes,content.media_type=:media)," + "'attempt',jsonb_build_array(attempt.id=:attempt," + "attempt.model_call_attempt_id=:effect,attempt.checker_run_id is null," + "attempt.producer_request_type='setup_model_output'," + "attempt.logical_role='model_request',attempt.sha256=:sha," + "attempt.byte_count=:bytes,attempt.media_type=:media," + "attempt.status='object_confirmed',attempt.checker_output_custody_sealed," + "attempt.replica_id=replica.id," + "attempt.storage_namespace_id=replica.storage_namespace_id," + "attempt.namespace_fingerprint=replica.namespace_fingerprint," + "attempt.canonical_target=replica.provider_object_ref)," + "'receipt',jsonb_build_array(binding.verification_receipt_id=:receipt," + "receipt.outcome='verified'," + "receipt.execution_generation=job.execution_generation," + "receipt.observed_sha256=:sha,receipt.observed_byte_count=:bytes)," + "'job',jsonb_build_array(job.originating_put_attempt_id=attempt.id," + "job.status='verified',job.terminal_result_code='verified'," + "job.terminal_at is not null,job.checker_output_custody_sealed)," + "'replica',jsonb_build_array(replica.content_id=content.id," + "replica.checker_output_custody_sealed," + "replica.verification_state='verified'," + "replica.availability_state='available',replica.integrity_state='valid')) " + "from artifact_bindings binding " + "left join artifact_contents content on content.id=binding.content_id " + "left join artifact_put_attempts attempt on attempt.id=binding.put_attempt_id " + "left join artifact_verification_receipts receipt " + "on receipt.id=binding.verification_receipt_id " + "left join artifact_verification_jobs job " + "on job.id=receipt.verification_job_id " + "left join artifact_replicas replica on replica.id=job.replica_id " + "where binding.id=:binding" + ), + { + "binding": str(request_evidence.binding_id), + "content": str(request_evidence.content_id), + "attempt": str(request_evidence.put_attempt_id), + "receipt": str(request_evidence.verification_receipt_id), + "effect": str(prepared.effect_id), + "sha": request_evidence.content_sha256, + "bytes": request_evidence.byte_count, + "media": request_evidence.media_type, + }, + ) + self.journey.evidence_diagnostics.append(diagnostic) row = ( await self.session.execute( text( @@ -585,6 +638,7 @@ async def _journey(storage: _StorageCase, access) -> _Journey: [], stages, [], + [], [True], observed_outputs, ) @@ -712,9 +766,18 @@ async def test_setup_response_recovers_from_verified_minio_after_ledger_loss( first = await journey.handler().handle(journey.request) assert first.outcome == "unavailable" assert first.failure_code == "model_call_unavailable" - assert [f"{type(exc).__name__}:{exc}" for exc in journey.failures] == [ - "ModelCallUnavailable:simulated_setup_ledger_unavailable" - ] + failures = [f"{type(exc).__name__}:{exc}" for exc in journey.failures] + if failures != ["ModelCallUnavailable:simulated_setup_ledger_unavailable"]: + raise AssertionError( + json.dumps( + { + "failures": failures, + "stages": journey.stages, + "evidence": journey.evidence_diagnostics, + }, + sort_keys=True, + ) + ) assert journey.reserve_observations == [("setup_model_call", "model_request", "verified")] assert journey.provider_calls == ["dispatch"] assert journey.fail_completion == [False] From 275bc4a4e2b72c4e211f25d1dc7f2ef4371c81aa Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:36:56 +0100 Subject: [PATCH 045/115] ci(model-usage): reconcile owner inventory after shared ledger extraction --- .ci/behavior-ownership/partition.v1.json | 124 ++++++++++++------ .../shared/module-boundary-validator.json | 2 +- backend/scripts/behavior_ownership.py | 30 +++-- 3 files changed, 102 insertions(+), 54 deletions(-) diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 9c4d94a1a..fbde5c7fc 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -60,6 +60,10 @@ "group": "artifacts", "target": "backend/app/adapters/checkers/model_provider.py" }, + { + "group": "artifacts", + "target": "backend/app/adapters/checkers/model_proxy.py" + }, { "group": "artifacts", "target": "backend/app/adapters/checkers/model_proxy_transport.py" @@ -136,6 +140,14 @@ "group": "lifecycle", "target": "backend/app/adapters/project_agents/result_validation.py" }, + { + "group": "lifecycle", + "target": "backend/app/adapters/project_agents/setup_model_handler.py" + }, + { + "group": "lifecycle", + "target": "backend/app/adapters/project_agents/setup_model_proxy.py" + }, { "group": "lifecycle", "target": "backend/app/adapters/projects/contribution_validation.py" @@ -420,6 +432,10 @@ "group": "lifecycle", "target": "backend/app/interfaces/project_guide_runtime.py" }, + { + "group": "shared", + "target": "backend/app/interfaces/setup_model_proxy.py" + }, { "group": "shared", "target": "backend/app/main.py" @@ -476,6 +492,10 @@ "group": "shared", "target": "backend/app/modules/api_controls/service.py" }, + { + "group": "artifacts", + "target": "backend/app/modules/artifacts/api/checker_output_authority.py" + }, { "group": "artifacts", "target": "backend/app/modules/artifacts/api/operator_authority.py" @@ -648,6 +668,10 @@ "group": "auth", "target": "backend/app/modules/authorization/acceptance_source_contracts.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/action_definitions.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/adapter_binding_authorization.py" @@ -748,10 +772,6 @@ "group": "auth", "target": "backend/app/modules/authorization/catalogue.py" }, - { - "group": "auth", - "target": "backend/app/modules/authorization/checker_model_authorization.py" - }, { "group": "auth", "target": "backend/app/modules/authorization/checker_output_authorization.py" @@ -808,10 +828,6 @@ "group": "auth", "target": "backend/app/modules/authorization/domain/audit_targets.py" }, - { - "group": "auth", - "target": "backend/app/modules/authorization/domain/checker_models.py" - }, { "group": "auth", "target": "backend/app/modules/authorization/domain/checker_outputs.py" @@ -872,6 +888,10 @@ "group": "auth", "target": "backend/app/modules/authorization/domain/management_reads.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/model_usage.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/domain/outbox_dispatch.py" @@ -900,6 +920,10 @@ "group": "auth", "target": "backend/app/modules/authorization/domain/prepared_adapter_bindings.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/prepared_checker_runtime.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/domain/prepared_compilation.py" @@ -1004,6 +1028,10 @@ "group": "auth", "target": "backend/app/modules/authorization/management_read.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/model_usage_authorization.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/models.py" @@ -1208,14 +1236,6 @@ "group": "artifacts", "target": "backend/app/modules/checkers/api/materialization.py" }, - { - "group": "artifacts", - "target": "backend/app/modules/checkers/api/model_calls.py" - }, - { - "group": "artifacts", - "target": "backend/app/modules/checkers/api/model_grants.py" - }, { "group": "artifacts", "target": "backend/app/modules/checkers/api/model_profiles.py" @@ -1286,31 +1306,7 @@ }, { "group": "artifacts", - "target": "backend/app/modules/checkers/model_calls/custody.py" - }, - { - "group": "artifacts", - "target": "backend/app/modules/checkers/model_calls/grants.py" - }, - { - "group": "artifacts", - "target": "backend/app/modules/checkers/model_calls/models.py" - }, - { - "group": "artifacts", - "target": "backend/app/modules/checkers/model_calls/profiles.py" - }, - { - "group": "artifacts", - "target": "backend/app/modules/checkers/model_calls/proxy.py" - }, - { - "group": "artifacts", - "target": "backend/app/modules/checkers/model_calls/repository.py" - }, - { - "group": "artifacts", - "target": "backend/app/modules/checkers/model_calls/service.py" + "target": "backend/app/modules/checkers/model_usage_custody.py" }, { "group": "artifacts", @@ -1512,6 +1508,46 @@ "group": "lifecycle", "target": "backend/app/modules/contributions/units/service.py" }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/api.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/grants.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/inspection.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/models.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/output_custody.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/owner_ports.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/profiles.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/repository.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/service.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/setup.py" + }, { "group": "shared", "target": "backend/app/modules/outbox/api.py" @@ -1696,6 +1732,10 @@ "group": "lifecycle", "target": "backend/app/modules/projects/guide_compilation/live.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/projects/guide_compilation/model_usage.py" + }, { "group": "lifecycle", "target": "backend/app/modules/projects/guide_compilation/models.py" @@ -2441,7 +2481,7 @@ "target": "backend/scripts/validate_test_lane_evidence.py" } ], - "authority_digest": "fdc50e4a445dde95d5c3d077f60447c4a1b8d93b2490b1dea766c8a88019f5f5", + "authority_digest": "c261f58c68701163e8f4f7b95a1bb1d6247ad9e9ee3d291e5aa662dd26bb161f", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.ci/behavior-ownership/shared/module-boundary-validator.json b/.ci/behavior-ownership/shared/module-boundary-validator.json index 322401c55..ae0be76d0 100644 --- a/.ci/behavior-ownership/shared/module-boundary-validator.json +++ b/.ci/behavior-ownership/shared/module-boundary-validator.json @@ -37,7 +37,7 @@ "target": "backend/scripts/module_boundaries.py", "tests": [ "backend/tests/architecture/test_module_boundaries.py::test_repository_matches_the_protected_module_boundary", - "backend/tests/architecture/test_module_boundaries.py::test_registry_names_exactly_nine_business_and_three_supporting_modules", + "backend/tests/architecture/test_module_boundaries.py::test_registry_names_exactly_ten_business_and_three_supporting_modules", "backend/tests/architecture/test_module_boundaries.py::test_new_private_edge_fails_exact_ledger_comparison", "backend/tests/architecture/test_module_boundaries.py::test_import_from_modules_package_resolves_registered_alias", "backend/tests/architecture/test_module_boundaries.py::test_unregistered_top_level_module_file_fails_closed", diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index f470494a9..8a808483f 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -118,28 +118,36 @@ CHECKER_MODEL_RUNTIME_TARGETS = frozenset({ "backend/app/adapters/checkers/model_execution.py", "backend/app/adapters/checkers/model_provider.py", + "backend/app/adapters/checkers/model_proxy.py", "backend/app/adapters/checkers/model_proxy_transport.py", "backend/app/adapters/checkers/model_runtime.py", + "backend/app/adapters/project_agents/setup_model_handler.py", + "backend/app/adapters/project_agents/setup_model_proxy.py", "backend/app/api/deps/checker_models.py", "backend/app/interfaces/checker_model_provider.py", "backend/app/interfaces/checker_model_proxy.py", + "backend/app/interfaces/setup_model_proxy.py", "backend/app/modules/artifacts/api/checker_output_authority.py", "backend/app/modules/artifacts/checker_model_evidence.py", - "backend/app/modules/authorization/checker_model_authorization.py", + "backend/app/modules/authorization/action_definitions.py", "backend/app/modules/authorization/checker_output_authorization.py", - "backend/app/modules/authorization/domain/checker_models.py", "backend/app/modules/authorization/domain/checker_outputs.py", + "backend/app/modules/authorization/domain/model_usage.py", "backend/app/modules/authorization/domain/prepared_checker_runtime.py", - "backend/app/modules/checkers/api/model_calls.py", - "backend/app/modules/checkers/api/model_grants.py", + "backend/app/modules/authorization/model_usage_authorization.py", "backend/app/modules/checkers/api/model_profiles.py", - "backend/app/modules/checkers/model_calls/custody.py", - "backend/app/modules/checkers/model_calls/grants.py", - "backend/app/modules/checkers/model_calls/models.py", - "backend/app/modules/checkers/model_calls/profiles.py", - "backend/app/modules/checkers/model_calls/proxy.py", - "backend/app/modules/checkers/model_calls/repository.py", - "backend/app/modules/checkers/model_calls/service.py", + "backend/app/modules/checkers/model_usage_custody.py", + "backend/app/modules/model_usage/api.py", + "backend/app/modules/model_usage/grants.py", + "backend/app/modules/model_usage/inspection.py", + "backend/app/modules/model_usage/models.py", + "backend/app/modules/model_usage/output_custody.py", + "backend/app/modules/model_usage/owner_ports.py", + "backend/app/modules/model_usage/profiles.py", + "backend/app/modules/model_usage/repository.py", + "backend/app/modules/model_usage/service.py", + "backend/app/modules/model_usage/setup.py", + "backend/app/modules/projects/guide_compilation/model_usage.py", "backend/app/workers/checker_model_proxy.py", }) PILOT_READ_1_TARGETS = frozenset({ From dce753cb4c5045bc65a18800d4a7a9fb576bd863 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:38:59 +0100 Subject: [PATCH 046/115] test(setup): expose output verification result --- .../test_setup_model_handler_postgresql.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py index aab86271e..4e817b1d3 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py +++ b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py @@ -574,7 +574,10 @@ async def store_setup_output(self, request): except Exception as exc: self.stages.append(f"store_{role}_failed:{type(exc).__name__}:{exc}") raise - self.stages.append(f"store_{role}_done") + self.stages.append( + f"store_{role}_done:{result.status}:" + f"receipt={result.verification_receipt_id is not None}" + ) return result From cfc264510514ada4608d151c5dca30ddf6d33fe3 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:42:02 +0100 Subject: [PATCH 047/115] test(setup): capture output verification state --- .../test_setup_model_handler_postgresql.py | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py index 4e817b1d3..70c63418b 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py +++ b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py @@ -386,6 +386,7 @@ class _Journey: stages: list[str] failures: list[BaseException] evidence_diagnostics: list[dict[str, object] | None] + output_diagnostics: list[dict[str, object] | None] fail_completion: list[bool] outputs: object @@ -559,9 +560,11 @@ async def complete(self, *args, **kwargs): class _ObservedOutputs: - def __init__(self, owner, stages: list[str]) -> None: + def __init__(self, owner, sessions, stages: list[str], diagnostics) -> None: self.owner = owner + self.sessions = sessions self.stages = stages + self.diagnostics = diagnostics def __getattr__(self, name): return getattr(self.owner, name) @@ -578,6 +581,28 @@ async def store_setup_output(self, request): f"store_{role}_done:{result.status}:" f"receipt={result.verification_receipt_id is not None}" ) + async with self.sessions() as session: + diagnostic = await session.scalar( + text( + "select jsonb_build_object(" + "'attempt_status',attempt.status," + "'attempt_result',attempt.terminal_result_code," + "'job_status',job.status,'job_result',job.terminal_result_code," + "'replica_verification',replica.verification_state," + "'replica_availability',replica.availability_state," + "'replica_integrity',replica.integrity_state," + "'receipt_outcome',receipt.outcome) " + "from artifact_put_attempts attempt " + "left join artifact_verification_jobs job " + "on job.originating_put_attempt_id=attempt.id " + "left join artifact_replicas replica on replica.id=job.replica_id " + "left join artifact_verification_receipts receipt " + "on receipt.verification_job_id=job.id " + "where attempt.id=:attempt order by job.id desc limit 1" + ), + {"attempt": str(result.put_attempt_id)}, + ) + self.diagnostics.append(diagnostic) return result @@ -630,7 +655,13 @@ async def _journey(storage: _StorageCase, access) -> _Journey: owner=setup_model_usage_owner, ) stages: list[str] = [] - observed_outputs = _ObservedOutputs(outputs, stages) + output_diagnostics: list[dict[str, object] | None] = [] + observed_outputs = _ObservedOutputs( + outputs, + storage.factory, + stages, + output_diagnostics, + ) return _Journey( storage, profile, @@ -642,6 +673,7 @@ async def _journey(storage: _StorageCase, access) -> _Journey: stages, [], [], + output_diagnostics, [True], observed_outputs, ) @@ -777,6 +809,7 @@ async def test_setup_response_recovers_from_verified_minio_after_ledger_loss( "failures": failures, "stages": journey.stages, "evidence": journey.evidence_diagnostics, + "output": journey.output_diagnostics, }, sort_keys=True, ) From 04bbac1129ed691ea7b8fb10b07c8ca5904db3ff Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:53:35 +0100 Subject: [PATCH 048/115] test(setup): provision binding authority actor --- .../checkers_model_calls/test_setup_model_handler_postgresql.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py index 70c63418b..5dfa06802 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py +++ b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py @@ -353,6 +353,7 @@ async def _storage_case(tmp_path, database_url, monkeypatch) -> AsyncIterator[_S ) for service_identity in ( ServiceIdentity.ARTIFACT_CHECKER_OUTPUT, + ServiceIdentity.ARTIFACT_BINDING, ServiceIdentity.ARTIFACT_PUT_RESOLVER, ServiceIdentity.ARTIFACT_VERIFIER, ): From 9a57c8831818d8180dac991de622424b2b7f85e9 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:56:30 +0100 Subject: [PATCH 049/115] fix(hosted): connect checker model relay and validate socket runtime --- deploy/hosted/README.md | 22 ++++- deploy/hosted/checker-service.example.json | 2 + deploy/hosted/compose.inference.yml | 13 +++ deploy/hosted/preflight.py | 31 ++++++- external_checkers/README.md | 6 +- .../tests/model_relay_docker.py | 12 ++- scripts/test_hosted_release_config.py | 80 ++++++++++++++++++- 7 files changed, 158 insertions(+), 8 deletions(-) diff --git a/deploy/hosted/README.md b/deploy/hosted/README.md index be07c496f..8e86ca5d9 100644 --- a/deploy/hosted/README.md +++ b/deploy/hosted/README.md @@ -106,7 +106,12 @@ that no smoke container owned by that run remains after inspection or timeout. Install and configure Docker and gVisor using the approved host process. Confirm that Docker reports a `runsc` runtime. Installation and daemon configuration are -outside this repository configuration. +outside this repository configuration. When model access is enabled, the outer +checker runtime must include exactly `--host-uds=open` so its invocation-local +Unix socket works under gVisor; full preflight checks Docker's reported runtime +arguments. This permits the mounted socket, not network access or a global proxy +mount inside the image. A separate trial runtime remains configured with host +Unix sockets disabled. The hosted profile fixes backend processes at UID/GID 10001 and the parser broker at UID 10002 with the shared client group 10001 after its identity bootstrap. @@ -117,6 +122,7 @@ create the fixed private directories with these exact identities: sudo install -d -m 0700 -o 10001 -g 10001 /var/lib/workstream/scratch sudo install -d -m 0700 -o 10001 -g 10001 /var/lib/workstream/checker-runtime sudo install -d -m 0700 -o 10001 -g 10001 /var/lib/workstream/model-runtime +sudo install -d -m 0700 -o 10001 -g 10001 /var/lib/workstream/model-relays sudo install -d -m 0750 -o 10002 -g 10001 /var/lib/workstream/guide-runtime stat -c '%g' /var/run/docker.sock ``` @@ -154,6 +160,15 @@ current artifact-provider activation before scheduling jobs. An absent required startup, and an unrelated secret is neither mounted nor loaded. The loader independently requires the running identity and mode `0400`, performs no shell evaluation and never prints a value. Do not put AWS static keys in these files. + +With the proxy enabled, the trusted checker service also mounts the global +model socket directory read-only. Its separate writable +`/var/lib/workstream/model-relays` directory has the same absolute path on the +Docker host and in the service, owned by the runtime UID/GID with mode `0700`. +The service waits for the proxy to become healthy and mounts only the current +invocation's relay into a project image. Project images receive neither the +global socket nor provider credentials. API, Beat and proof jobs receive +neither model directory. The two provider selectors are trusted release configuration. The proxy loads only its dedicated files as `OPENROUTER_API_KEY` and `OPENAI_API_KEY`; this is independent of the setup provider because approved POST and setup profiles may @@ -232,6 +247,11 @@ activation. Copy `checker-service.example.json` to the configured private path, mode `0400` and the same runtime owner. Replace the example cache with every approved single-platform checker image supplied by the project checker/registry work. +The example includes the exact `model_proxy_socket` and `model_relay_root` +selectors for a proxy-enabled release. Remove both keys when +`WORKSTREAM_MODEL_PROXY_ENABLED=false`; preflight rejects partial, substituted +or disabled-proxy selectors. Rust consumes these paths from the private JSON +configuration, not environment variables. For each entry, preserve the exact repository, platform manifest digest/media type/byte count, Linux architecture and Docker config image ID. Pull every `repository@sha256:...` identity before preflight. Full preflight checks that diff --git a/deploy/hosted/checker-service.example.json b/deploy/hosted/checker-service.example.json index 3018225f9..718061488 100644 --- a/deploy/hosted/checker-service.example.json +++ b/deploy/hosted/checker-service.example.json @@ -1,6 +1,8 @@ { "socket_path": "/var/lib/workstream/checker-runtime/checker.sock", "material_root": "/var/lib/workstream/scratch", + "model_proxy_socket": "/var/lib/workstream/model-runtime/model.sock", + "model_relay_root": "/var/lib/workstream/model-relays", "docker_binary": "/usr/local/bin/docker", "runtime": "runsc", "isolation_mode": "gvisor", diff --git a/deploy/hosted/compose.inference.yml b/deploy/hosted/compose.inference.yml index 44bce55c0..0a7561002 100644 --- a/deploy/hosted/compose.inference.yml +++ b/deploy/hosted/compose.inference.yml @@ -1,4 +1,17 @@ services: + checker-service: + volumes: + - type: bind + source: /var/lib/workstream/model-runtime + target: /var/lib/workstream/model-runtime + read_only: true + - type: bind + source: /var/lib/workstream/model-relays + target: /var/lib/workstream/model-relays + depends_on: + model-proxy: + condition: service_healthy + worker: environment: WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET: /var/lib/workstream/model-runtime/model.sock diff --git a/deploy/hosted/preflight.py b/deploy/hosted/preflight.py index 0215f5f3d..73a4a180e 100644 --- a/deploy/hosted/preflight.py +++ b/deploy/hosted/preflight.py @@ -27,6 +27,7 @@ GUIDE_SOCKET_PATH = GUIDE_SOCKET_ROOT / "guide.sock" MODEL_SOCKET_ROOT = Path("/var/lib/workstream/model-runtime") MODEL_SOCKET_PATH = MODEL_SOCKET_ROOT / "model.sock" +MODEL_RELAY_ROOT = Path("/var/lib/workstream/model-relays") GUIDE_PARSER_UID = 10002 GUIDE_PARSER_GID = 10001 DOCKER_SOCKET = Path("/var/run/docker.sock") @@ -445,6 +446,11 @@ def validate_checker_config(config: dict[str, Any], values: dict[str, str]) -> N "sandbox_uid": _positive_id(values, "WORKSTREAM_RUNTIME_UID"), "sandbox_gid": _positive_id(values, "WORKSTREAM_RUNTIME_GID"), } + model_proxy_enabled = values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true" + expected.update({ + "model_proxy_socket": str(MODEL_SOCKET_PATH) if model_proxy_enabled else None, + "model_relay_root": str(MODEL_RELAY_ROOT) if model_proxy_enabled else None, + }) for key, required in expected.items(): if config.get(key) != required: raise PreflightError( @@ -521,6 +527,9 @@ def validate_private_host_inputs(values: dict[str, str]) -> None: _private_metadata( MODEL_SOCKET_ROOT, kind="directory", uid=uid, gid=gid, mode=0o700 ) + _private_metadata( + MODEL_RELAY_ROOT, kind="directory", uid=uid, gid=gid, mode=0o700 + ) _private_metadata( GUIDE_SOCKET_ROOT, kind="directory", @@ -574,6 +583,7 @@ def validate_private_host_inputs(values: dict[str, str]) -> None: ] if model_proxy_enabled: shared_roots.append(MODEL_SOCKET_ROOT.resolve(strict=True)) + shared_roots.append(MODEL_RELAY_ROOT.resolve(strict=True)) resolved_paths = [path.resolve(strict=True) for path in private_paths] except (OSError, RuntimeError) as exc: raise PreflightError("private host input identity is unavailable") from exc @@ -950,7 +960,12 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: str(SCRATCH_ROOT): (str(SCRATCH_ROOT), True), str(SOCKET_ROOT): (str(SOCKET_ROOT), False), str(DOCKER_SOCKET): (str(DOCKER_SOCKET), False), - }, + } | ( + { + str(MODEL_SOCKET_ROOT): (str(MODEL_SOCKET_ROOT), True), + str(MODEL_RELAY_ROOT): (str(MODEL_RELAY_ROOT), False), + } if model_proxy_enabled else {} + ), "api": shared_backend_mounts | runtime_identity_mounts, "worker": shared_backend_mounts | {str(GUIDE_SOCKET_ROOT): (str(GUIDE_SOCKET_ROOT), True)} @@ -1117,6 +1132,10 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: "required": True, }: raise PreflightError("worker must wait for a healthy model proxy") + if model_proxy_enabled and services["checker-service"].get("depends_on", {}).get( + "model-proxy" + ) != {"condition": "service_healthy", "required": True}: + raise PreflightError("checker service must wait for a healthy model proxy") worker = services["worker"] worker_command = worker.get("command") if ( @@ -1362,6 +1381,16 @@ def validate_docker_runtime(config: dict[str, Any], values: dict[str, str]) -> N ) if not isinstance(runtimes, dict) or "runsc" not in runtimes: raise PreflightError("Docker does not report the required runsc runtime") + if values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true": + runtime = runtimes["runsc"] + arguments = runtime.get("runtimeArgs", []) if isinstance(runtime, dict) else [] + if not isinstance(arguments, list) or [ + argument for argument in arguments + if isinstance(argument, str) and argument.startswith("--host-uds") + ] != ["--host-uds=open"]: + raise PreflightError( + "model-enabled checker runsc requires exactly --host-uds=open" + ) for entry in config["cache"]: reference = f"{entry['repository']}@{entry['platform_manifest_digest']}" inspected = _run_json( diff --git a/external_checkers/README.md b/external_checkers/README.md index e2f08118e..8be1fb1a2 100644 --- a/external_checkers/README.md +++ b/external_checkers/README.md @@ -121,12 +121,14 @@ and makes no provider call: ```console CARGO_TARGET_DIR=/dev/shm/workstream-checker-target \ cargo build --locked -p workstream-checker-service -TMPDIR=/dev/shm python3 \ +TMPDIR=/absolute/daemon-visible/private-directory python3 \ workstream-checker-service/tests/model_relay_docker.py \ --service /dev/shm/workstream-checker-target/debug/workstream-checker-service ``` -Run those commands from `external_checkers/`. The probe never pulls images, +Run those commands from `external_checkers/`. Set `TMPDIR` to a private directory +that resolves to the same files in both the command process and the Docker daemon; +a service-private `/tmp` or `/dev/shm` may not be shared. The probe never pulls images, scans Docker state for cleanup, or qualifies the gVisor path. Its fake reply is transport evidence only; it does not prove provider execution, budget settlement, or a completed product model-evaluation journey. diff --git a/external_checkers/workstream-checker-service/tests/model_relay_docker.py b/external_checkers/workstream-checker-service/tests/model_relay_docker.py index 87080ef80..87686042a 100644 --- a/external_checkers/workstream-checker-service/tests/model_relay_docker.py +++ b/external_checkers/workstream-checker-service/tests/model_relay_docker.py @@ -7,7 +7,7 @@ and removed explicitly; the probe never scans or prunes Docker state. Usage: - TMPDIR=/dev/shm python3 model_relay_docker.py \ + TMPDIR=/absolute/daemon-visible/private-directory python3 model_relay_docker.py \ --service /absolute/path/to/workstream-checker-service """ @@ -31,7 +31,7 @@ PROTOCOL = "external_checker_service.v1" GRANT_PROTOCOL = "workstream.external_checker_material_grant.v1" -MODEL_PROTOCOL = "workstream.checker_model_proxy.v1" +MODEL_PROTOCOL = "workstream.model_session.v2" MAX_FRAME = 1024 * 1024 MODEL_GRANT_ID = "model_0123456789abcdef0123456789abcdef" MATERIAL_GRANT_ID = "extract_0123456789abcdef0123456789abcdef" @@ -42,6 +42,8 @@ "output": "docker-relay-ok", "receipt_digest": "sha256:" + "e" * 64, "failure_code": None, + "call_index": 0, + "session_exhausted": True, } @@ -367,7 +369,7 @@ def write_checker_sources(root: Path) -> tuple[Path, Path]: } int main(void) { - static const char body[] = "{\"prompt\":\"docker relay proof\"}"; + static const char body[] = "{\"protocol_version\":\"workstream.model_session.v2\",\"operation\":\"call\",\"call_index\":0,\"prompt\":\"docker relay proof\",\"required_receipt_handles\":[]}"; struct sockaddr_un address = {0}; int descriptor = socket(AF_UNIX, SOCK_STREAM, 0); if (descriptor < 0) return 20; @@ -442,7 +444,9 @@ def fake_proxy( "protocol_version": MODEL_PROTOCOL, "grant_id": MODEL_GRANT_ID, "request_digest": expected_digest, + "call_index": 0, "prompt": PROMPT, + "resolved_receipts": [], } if request != expected: raise RuntimeError("proxy received a different closed request") @@ -690,7 +694,9 @@ def main() -> None: "protocol_version": MODEL_PROTOCOL, "grant_id": MODEL_GRANT_ID, "request_digest": request["request_digest"], + "call_index": 0, "prompt": PROMPT, + "resolved_receipts": [], }, "reply": MODEL_REPLY["output"], "checker_container_removed": True, diff --git a/scripts/test_hosted_release_config.py b/scripts/test_hosted_release_config.py index 9a831b72b..8ce8890a4 100644 --- a/scripts/test_hosted_release_config.py +++ b/scripts/test_hosted_release_config.py @@ -309,12 +309,18 @@ def test_valid_configuration_and_rendered_topology(self) -> None: "read_only", False ) ) - for name in ("api", "beat", "checker-service", *preflight.PROOF_SERVICES): + for name in ("api", "beat", *preflight.PROOF_SERVICES): self.assertNotIn( model_socket_root, preflight._service_mounts(services[name]), ) checker_mounts = preflight._service_mounts(services["checker-service"]) + self.assertTrue(checker_mounts[model_socket_root]["read_only"]) + relay_root = str(preflight.MODEL_RELAY_ROOT) + self.assertEqual(checker_mounts[relay_root]["source"], relay_root) + self.assertFalse(checker_mounts[relay_root].get("read_only", False)) + for name in ("api", "worker", "beat", "model-proxy", *preflight.PROOF_SERVICES): + self.assertNotIn(relay_root, preflight._service_mounts(services[name])) self.assertNotIn( str(preflight.ROLES_ANYWHERE_CERTIFICATE_TARGET), checker_mounts ) @@ -1057,6 +1063,66 @@ def test_checker_config_rejects_non_hosted_or_substituted_identity(self) -> None with self.assertRaises(preflight.PreflightError): preflight.validate_checker_config(changed, self.environment) + def test_model_runtime_requires_usable_host_socket(self) -> None: + config = self.checker | {"cache": []} + for arguments in ([], ["--host-uds=none"], ["--host-uds=open", "--host-uds=none"]): + with self.subTest(arguments=arguments), patch.object( + preflight, "_run_json", return_value={"runsc": {"runtimeArgs": arguments}} + ): + with self.assertRaisesRegex(preflight.PreflightError, "host-uds"): + preflight.validate_docker_runtime(config, self.environment) + with patch.object( + preflight, "_run_json", + return_value={"runsc": {"runtimeArgs": ["--host-uds=open"]}}, + ): + preflight.validate_docker_runtime(config, self.environment) + with patch.object( + preflight, "_run_json", return_value={"runsc": {"runtimeArgs": []}} + ): + preflight.validate_docker_runtime( + config, self.environment | {"WORKSTREAM_MODEL_PROXY_ENABLED": "false"} + ) + + def test_checker_model_paths_require_enabled_exact_proxy_topology(self) -> None: + for key in ("model_proxy_socket", "model_relay_root"): + for replacement in (None, "/tmp/substituted"): + with self.subTest(key=key, replacement=replacement): + changed = copy.deepcopy(self.checker) + changed[key] = replacement + with self.assertRaises(preflight.PreflightError): + preflight.validate_checker_config(changed, self.environment) + disabled = self.environment | {"WORKSTREAM_MODEL_PROXY_ENABLED": "false"} + with self.assertRaises(preflight.PreflightError): + preflight.validate_checker_config(self.checker, disabled) + ordinary = copy.deepcopy(self.checker) + del ordinary["model_proxy_socket"] + del ordinary["model_relay_root"] + preflight.validate_checker_config(ordinary, disabled) + + def test_rendered_checker_model_relay_cannot_lose_custody(self) -> None: + rendered = preflight.render_compose(self.environment_path, self.environment) + for target, read_only in ( + (str(preflight.MODEL_SOCKET_ROOT), False), + (str(preflight.MODEL_RELAY_ROOT), True), + ): + for change in ("remove", "mode", "source"): + with self.subTest(target=target, change=change): + changed = copy.deepcopy(rendered) + mounts = changed["services"]["checker-service"]["volumes"] + selected = next(m for m in mounts if m["target"] == target) + if change == "remove": + mounts.remove(selected) + elif change == "mode": + selected["read_only"] = read_only + else: + selected["source"] = "/tmp/substituted" + with self.assertRaises(preflight.PreflightError): + preflight.validate_compose(changed, self.environment) + changed = copy.deepcopy(rendered) + del changed["services"]["checker-service"]["depends_on"]["model-proxy"] + with self.assertRaises(preflight.PreflightError): + preflight.validate_compose(changed, self.environment) + def test_rendered_topology_rejects_privilege_and_path_drift(self) -> None: rendered = preflight.render_compose(self.environment_path, self.environment) cases = [] @@ -1317,10 +1383,12 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( scratch = self.root / "scratch" socket_root = self.root / "socket" model_socket_root = self.root / "model-socket" + model_relay_root = self.root / "model-relays" guide_socket_root = self.root / "guide-socket" scratch.mkdir(mode=0o700) socket_root.mkdir(mode=0o700) model_socket_root.mkdir(mode=0o700) + model_relay_root.mkdir(mode=0o700) guide_socket_root.mkdir(mode=0o750) host_uid = os.getuid() host_gid = os.getgid() @@ -1349,6 +1417,7 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object(preflight, "MODEL_RELAY_ROOT", model_relay_root), patch.object( preflight, "MODEL_SOCKET_PATH", @@ -1365,6 +1434,10 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( patch.object(preflight, "DOCKER_SOCKET", docker_socket), ): preflight.validate_private_host_inputs(environment) + model_relay_root.chmod(0o755) + with self.assertRaisesRegex(preflight.PreflightError, "wrong mode"): + preflight.validate_private_host_inputs(environment) + model_relay_root.chmod(0o700) guide_listener = socket.socket(socket.AF_UNIX) guide_listener.bind(str(guide_socket_root / "guide.sock")) (guide_socket_root / "guide.sock").chmod(0o660) @@ -1415,6 +1488,7 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object(preflight, "MODEL_RELAY_ROOT", model_relay_root), patch.object( preflight, "MODEL_SOCKET_PATH", @@ -1449,6 +1523,7 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object(preflight, "MODEL_RELAY_ROOT", model_relay_root), patch.object( preflight, "MODEL_SOCKET_PATH", @@ -1480,6 +1555,7 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object(preflight, "MODEL_RELAY_ROOT", model_relay_root), patch.object( preflight, "MODEL_SOCKET_PATH", @@ -1505,6 +1581,7 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object(preflight, "MODEL_RELAY_ROOT", model_relay_root), patch.object( preflight, "MODEL_SOCKET_PATH", @@ -1536,6 +1613,7 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object(preflight, "MODEL_RELAY_ROOT", model_relay_root), patch.object( preflight, "MODEL_SOCKET_PATH", From c0dbf4ffb8eba4ee7573067416dfb4287a2b6e67 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 22:57:47 +0100 Subject: [PATCH 050/115] test(setup): compare diagnostic effect as uuid --- .../checkers_model_calls/test_setup_model_handler_postgresql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py index 5dfa06802..950fd1b44 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py +++ b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py @@ -488,7 +488,7 @@ async def reserve_dispatch(self, prepared, request_evidence): text( "select jsonb_build_object(" "'binding',jsonb_build_array(binding.resource_type='setup_model_call'," - "binding.resource_id=:effect,binding.logical_role='model_request'," + "binding.resource_id::uuid=:effect,binding.logical_role='model_request'," "binding.scope_version=1,binding.attribution_type='service_identity'," "binding.actor_id='workstream.artifact.checker_output')," "'content',jsonb_build_array(binding.content_id=:content," From b3b9e358fcb4e46f701c6e95c25a600110df113d Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 23:01:24 +0100 Subject: [PATCH 051/115] test(projects): serialize retained schema upgrades --- .../guide_compilation/test_runtime_cleanup_postgresql.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/tests/projects/guide_compilation/test_runtime_cleanup_postgresql.py b/backend/tests/projects/guide_compilation/test_runtime_cleanup_postgresql.py index 1bf449342..7eaf4678d 100644 --- a/backend/tests/projects/guide_compilation/test_runtime_cleanup_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_runtime_cleanup_postgresql.py @@ -21,6 +21,7 @@ from .helpers import runtime_configuration RETAINED_RUNTIME = "openai_agents_sdk" +pytestmark = pytest.mark.postgres_schema_contract def _historical_configuration(): From 734bc22ce2b21e0bccb1636878c8b6713b5419e1 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 23:01:59 +0100 Subject: [PATCH 052/115] test(projects): keep scripted proposals provider-free --- .../tests/projects/guide_compilation/proposals/pg_support.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/projects/guide_compilation/proposals/pg_support.py b/backend/tests/projects/guide_compilation/proposals/pg_support.py index 33c760bba..0da4335a9 100644 --- a/backend/tests/projects/guide_compilation/proposals/pg_support.py +++ b/backend/tests/projects/guide_compilation/proposals/pg_support.py @@ -255,7 +255,7 @@ async def request_corrected_attempt(factory, actor, correction, *, capabilities= from tests.checkers.external.support import capability_projection from app.modules.projects.guide_compilation.request_inputs import CompilationRequestInputs from app.modules.projects.guide_compilation.service import GuideCompilationService - from tests.projects.guide_compilation.helpers import runtime_configuration + from tests.projects.guide_compilation.helpers import manager_runtime_configuration ctx = HumanAuthorizationContext( actor_profile_id=actor.actor_profile_id, @@ -326,7 +326,7 @@ async def no_model_session(_state, _context): checker_capabilities=capabilities, request_authority=guide_compilation_request_authority, execution_authority=guide_compilation_execution_authority, - configuration_factory=runtime_configuration, + configuration_factory=manager_runtime_configuration, model_profiles=lambda _session: None, runtime_factory=lambda configuration: runtime, sufficiency_authorization_factory=guide_sufficiency_projection_authorization, From 528e2252c7d09b2d659ae04089198a6ec181a097 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 23:02:12 +0100 Subject: [PATCH 053/115] fix(test): scope proposal runtime fixtures --- .../tests/projects/guide_compilation/proposals/pg_support.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/projects/guide_compilation/proposals/pg_support.py b/backend/tests/projects/guide_compilation/proposals/pg_support.py index 0da4335a9..68e0db44f 100644 --- a/backend/tests/projects/guide_compilation/proposals/pg_support.py +++ b/backend/tests/projects/guide_compilation/proposals/pg_support.py @@ -255,7 +255,7 @@ async def request_corrected_attempt(factory, actor, correction, *, capabilities= from tests.checkers.external.support import capability_projection from app.modules.projects.guide_compilation.request_inputs import CompilationRequestInputs from app.modules.projects.guide_compilation.service import GuideCompilationService - from tests.projects.guide_compilation.helpers import manager_runtime_configuration + from tests.projects.guide_compilation.helpers import runtime_configuration ctx = HumanAuthorizationContext( actor_profile_id=actor.actor_profile_id, @@ -299,7 +299,7 @@ async def finalize_corrected_attempt(factory, values, actor, correction, *, outc from app.modules.projects.api import ProjectGuideSetupFinalizationCommand from app.modules.projects.api.guide_compilation import ProjectGuideCompilationDelivery from app.modules.projects.api.setup_identity import project_guide_compilation_task_id - from tests.projects.guide_compilation.helpers import runtime_configuration + from tests.projects.guide_compilation.helpers import manager_runtime_configuration from tests.projects.guide_compilation.runtime_fixtures import ( ScriptedGuideRuntime, document_tools, From e8cf3041c84b627982728b82d00a3bf85164230e Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 23:05:10 +0100 Subject: [PATCH 054/115] fix(cli): accept current model permission catalogue --- cli/internal/api/admin_inspection.go | 4 ++-- .../integration/test_admin_inspection_http.py | 6 +++--- .../test_admin_inspection_public_api.py | 2 +- mcp_server/tests/integration/test_access_reads.py | 15 +++++++++++---- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/cli/internal/api/admin_inspection.go b/cli/internal/api/admin_inspection.go index 1c9e1a9cd..19e5aa1af 100644 --- a/cli/internal/api/admin_inspection.go +++ b/cli/internal/api/admin_inspection.go @@ -303,8 +303,8 @@ func (c *Client) AdminPermissions(ctx context.Context) (Result[PermissionCatalog } var value PermissionCatalogue fields, err := contextObject(raw, &value, []string{"items", "total"}, nil) - // The public PermissionDefinitionsResponse schema fixes total to Literal[82]. - if err != nil || value.Items == nil || value.Total != 82 || len(value.Items) != value.Total || !c.safeMutationResponse(raw) { + // The public PermissionDefinitionsResponse schema fixes total to Literal[95]. + if err != nil || value.Items == nil || value.Total != 95 || len(value.Items) != value.Total || !c.safeMutationResponse(raw) { return result, &Failure{Code: "invalid_api_response"} } var members []json.RawMessage diff --git a/cli/tests/integration/test_admin_inspection_http.py b/cli/tests/integration/test_admin_inspection_http.py index 5df82f600..de5bb4147 100644 --- a/cli/tests/integration/test_admin_inspection_http.py +++ b/cli/tests/integration/test_admin_inspection_http.py @@ -59,8 +59,8 @@ "revoked_at": None, } PERMISSIONS = { - "items": [{"permission_id": f"test.permission_{i}"} for i in range(82)], - "total": 82, + "items": [{"permission_id": f"test.permission_{i}"} for i in range(95)], + "total": 95, } DEFINITIONS = { "items": [ @@ -447,7 +447,7 @@ def test_admin_rejects_malformed_and_mismatched_success(cli): assert_failure(result, "invalid_api_response") for command, body in ( ("permissions", PERMISSIONS | {"total": 81}), - ("permissions", PERMISSIONS | {"items": [PERMISSIONS["items"][0]] * 82}), + ("permissions", PERMISSIONS | {"items": [PERMISSIONS["items"][0]] * 95}), ("role-definitions", DEFINITIONS | {"items": DEFINITIONS["items"][:-1]}), ( "role-definitions", diff --git a/cli/tests/integration/test_admin_inspection_public_api.py b/cli/tests/integration/test_admin_inspection_public_api.py index 17ad2c0d3..950744c02 100644 --- a/cli/tests/integration/test_admin_inspection_public_api.py +++ b/cli/tests/integration/test_admin_inspection_public_api.py @@ -165,7 +165,7 @@ async def issue( assert value == expected.json() assert ( inspected(cli, origin, tokens["auditor"], "permissions")["total"] - == 82 + == 95 ) assert ( inspected(cli, origin, tokens["auditor"], "role-definitions")[ diff --git a/mcp_server/tests/integration/test_access_reads.py b/mcp_server/tests/integration/test_access_reads.py index fafb1a6b7..df6da8101 100644 --- a/mcp_server/tests/integration/test_access_reads.py +++ b/mcp_server/tests/integration/test_access_reads.py @@ -202,10 +202,17 @@ async def _exercise_access_reads( if allowed and "items" in body: assert body["items"] and body["total"] >= len(body["items"]) if allowed and name == "workstream_permissions_list": - assert body["total"] == len(body["items"]) == 91 - assert {"task.revision.expire", "task.revision.recover"} <= { - item["permission_id"] for item in body["items"] - } + assert body["total"] == len(body["items"]) == 95 + assert { + "task.revision.expire", + "task.revision.recover", + "project.model_profile.manage", + "project.model_budget.manage", + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.observe", + "project.guide_compilation.model.complete", + } <= {item["permission_id"] for item in body["items"]} if allowed and name == "workstream_actor_get": assert body["display_name"] == "Access read target" From 07f0a419e51a326f24f034701cac114f42f2a88b Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 23:05:32 +0100 Subject: [PATCH 055/115] test(model-usage): reconcile active contracts and schema lane ownership --- backend/scripts/test_lane_catalogue.py | 2 +- .../tests/authorization/catalogue_fixtures.py | 17 +++++++++++++++ .../setup_finalization/test_catalogue.py | 21 +++++++++++++++++++ backend/tests/test_api_controls.py | 21 +++++++++++++++---- backend/tests/test_api_rate_controls.py | 10 +++++++++ backend/tests/test_artifact_architecture.py | 2 +- backend/tests/test_authorization.py | 6 ++++-- backend/tests/test_ci_lane_catalogue.py | 9 ++++---- 8 files changed, 76 insertions(+), 12 deletions(-) diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index 5131ccf22..b04a4ec54 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -437,7 +437,6 @@ class TestLane: "tests/projects/guide_compilation/test_document_access_postgresql.py", "tests/projects/guide_compilation/test_neutral_document_tools.py", "tests/projects/guide_compilation/test_document_file_custody_postgresql.py", - "tests/projects/guide_compilation/test_runtime_cleanup_postgresql.py", "tests/projects/guide_compilation/test_document_scope_contracts.py", "tests/projects/guide_compilation/test_task_examples_postgresql.py", "tests/projects/guide_compilation/test_database_guards.py", @@ -735,6 +734,7 @@ class TestLane: "schema_contracts", ( SCHEMA_MODULE, + "tests/projects/guide_compilation/test_runtime_cleanup_postgresql.py", "tests/migrations/test_contribution_read_audit.py", "tests/migrations/test_contribution_export_snapshots.py", "tests/authorization/post_submit/test_receipt_custody.py", diff --git a/backend/tests/authorization/catalogue_fixtures.py b/backend/tests/authorization/catalogue_fixtures.py index 065f966f5..6dea17bff 100644 --- a/backend/tests/authorization/catalogue_fixtures.py +++ b/backend/tests/authorization/catalogue_fixtures.py @@ -578,6 +578,23 @@ AUDIT_ALLOWED_ACTION_VALUES = { + "checker.model_call.prepare", + "checker.model_call.dispatch", + "checker.model_call.recover", + "checker.model_call.observe", + "checker.model_call.complete", + "checker.model_call.grant", + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.observe", + "project.guide_compilation.model.complete", + "project.model_profile.provision", + "project.model_budget.configure", + "project.model_usage.read", + "project.model_call.recovery_child.authorize", + "artifact.checker_output.write", + "artifact.checker_output.binding.create", + "task.revision.expire", "task.revision.recover", "checker.pre_submit.execute", diff --git a/backend/tests/authorization/setup_finalization/test_catalogue.py b/backend/tests/authorization/setup_finalization/test_catalogue.py index bf4d6bfc4..cea3f0d6d 100644 --- a/backend/tests/authorization/setup_finalization/test_catalogue.py +++ b/backend/tests/authorization/setup_finalization/test_catalogue.py @@ -29,6 +29,23 @@ EXPECTED_ACTIVE_ACTIONS = frozenset({ + ActionId.CHECKER_MODEL_CALL_PREPARE, + ActionId.CHECKER_MODEL_CALL_DISPATCH, + ActionId.CHECKER_MODEL_CALL_RECOVER, + ActionId.CHECKER_MODEL_CALL_OBSERVE, + ActionId.CHECKER_MODEL_CALL_COMPLETE, + ActionId.CHECKER_MODEL_CALL_GRANT, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + ActionId.PROJECT_MODEL_PROFILE_PROVISION, + ActionId.PROJECT_MODEL_BUDGET_CONFIGURE, + ActionId.PROJECT_MODEL_USAGE_READ, + ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, + ActionId.PROJECT_TASK_IMPORT, ActionId.PROJECT_TASK_IMPORT_BATCH_READ, ActionId.CONTRIBUTION_READ_SELF, ActionId.CONTRIBUTION_READ_PROJECT, ActionId.ACTOR_PROJECT_LIST, ActionId.ACTOR_ASSIGNMENT_LIST, ActionId.ACTOR_SUBMISSION_LIST, @@ -240,6 +257,10 @@ async def grant(*args, **kwargs): def test_setup_existing_active_pairs_and_downstream_plans_are_preserved(): expected = { + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE: PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH: PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE: PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE: PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC: PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE: PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: PermissionId.PROJECT_GUIDE_MANAGE, diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index ccfe71b91..7e8ec893f 100644 --- a/backend/tests/test_api_controls.py +++ b/backend/tests/test_api_controls.py @@ -633,13 +633,25 @@ def test_openapi_documents_request_error_and_response_context() -> None: assert repair_operation["x-workstream-action-id"] == "project.task.revision.repair" assert repair_operation["security"] new_manager_reads.add(revision_repair) - assert len(route_inventory) == 129 - retained_routes = sorted(set(route_inventory) - proposal_routes - post_policy_routes - queue_routes - new_manager_reads - guide_read - contribution_reads.keys() - public_entry_actions.keys() - registry_actions.keys() - recovery_actions.keys()) - retained_protected = sorted(set(protected_inventory) - proposal_routes - post_policy_routes - queue_routes - new_manager_reads - guide_read - contribution_reads.keys() - public_entry_actions.keys() - registry_actions.keys() - recovery_actions.keys()) + model_actions = { + "POST /api/v1/operations/projects/{project_id}/model-profiles": "project.model_profile.provision", + "POST /api/v1/operations/projects/{project_id}/model-budget-configurations": "project.model_budget.configure", + "GET /api/v1/operations/projects/{project_id}/model-usage/effects/{effect_id}": "project.model_usage.read", + "POST /api/v1/operations/projects/{project_id}/model-call-attempts/{parent_attempt_id}/recovery-children": "project.model_call.recovery_child.authorize", + } + for route, action in model_actions.items(): + assert route in protected_inventory + method, path = route.split(" ", 1) + operation = schema["paths"][path][method.lower()] + assert operation["x-workstream-action-id"] == action + assert operation["security"] + assert len(route_inventory) == 133 + retained_routes = sorted(set(route_inventory) - proposal_routes - post_policy_routes - queue_routes - new_manager_reads - guide_read - contribution_reads.keys() - public_entry_actions.keys() - registry_actions.keys() - recovery_actions.keys() - model_actions.keys()) + retained_protected = sorted(set(protected_inventory) - proposal_routes - post_policy_routes - queue_routes - new_manager_reads - guide_read - contribution_reads.keys() - public_entry_actions.keys() - registry_actions.keys() - recovery_actions.keys() - model_actions.keys()) assert sha256("\n".join(retained_routes).encode()).hexdigest() == ( "793237012e9256d308dd5d1e9c1a65e41381cf6306888fdd175dc9794b118444" ) - assert len(protected_inventory) == 127 + assert len(protected_inventory) == 131 assert sha256("\n".join(retained_protected).encode()).hexdigest() == ( "0953ae6392b81a4a7ba7c9f6b7fccc17768f34cd1f958572fdc553cd204ec82e" ) @@ -676,6 +688,7 @@ def test_openapi_documents_request_error_and_response_context() -> None: **public_entry_actions, **registry_actions, **recovery_actions, + **model_actions, **batch_actions, "GET /api/v1/tasks/{task_id}/submissions": "task.submission.list", "GET /api/v1/submissions/{submission_id}": "submission.read", diff --git a/backend/tests/test_api_rate_controls.py b/backend/tests/test_api_rate_controls.py index f5d0ea43c..2e5ffbaaf 100644 --- a/backend/tests/test_api_rate_controls.py +++ b/backend/tests/test_api_rate_controls.py @@ -1092,6 +1092,16 @@ def test_production_rate_dependencies_match_exact_route_allowlist() -> None: route: {enforce_authorization_read_rate_limit} for route in ESTABLISHED_AUTHORIZATION_READ_RATE_ROUTES }, + **{ + ("POST", f"/operations/projects/{{project_id}}/{suffix}"): {enforce_admin_mutation_rate_limit} + for suffix in ( + "model-profiles", "model-budget-configurations", + "model-call-attempts/{parent_attempt_id}/recovery-children", + ) + }, + ("GET", "/operations/projects/{project_id}/model-usage/effects/{effect_id}"): { + enforce_authorization_read_rate_limit + }, **CHECKER_REGISTRY_RATE_BINDINGS, **CHECKER_RECOVERY_RATE_BINDINGS, } diff --git a/backend/tests/test_artifact_architecture.py b/backend/tests/test_artifact_architecture.py index d0aa7bb6f..8886cb97c 100644 --- a/backend/tests/test_artifact_architecture.py +++ b/backend/tests/test_artifact_architecture.py @@ -379,7 +379,7 @@ def _assert_concrete_adapter_composition( ) assert Counter(factory_calls) == { COMPOSITION_ROOT: 1, - CHECKER_COMPOSITION_ROOT: 1, + CHECKER_COMPOSITION_ROOT: 2, # Inference and retained-resource cleanup are distinct typed capabilities. AGENT_COMPOSITION_ROOT: 2, OBSERVABILITY_COMPOSITION_ROOT: 1, diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index afcd7ccf4..547cd5203 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -2242,7 +2242,9 @@ def test_administrative_role_policy_and_definition_responses_are_exact() -> None project.effective_policy.read review.queue.inspect review.lease.force_release contribution.read_project operations.status.read operations.timer.run operations.reconcile.run operations.outbox.retry operations.projection.rebuild operations.task.start_override - operations.submission_gate.repair operations.checker.retry artifact.binding.read + operations.submission_gate.repair operations.checker.retry + project.model_profile.manage project.model_budget.manage + project.model_call.recovery_child.authorize artifact.binding.read artifact.replica.read artifact.receipt.read artifact.verification_job.read artifact.verification_job.retry artifact.recovery_attempt.read artifact.audit.read audit.read""".split(), @@ -2715,7 +2717,7 @@ async def record_conflict(self, **kwargs) -> None: definition.action_id, definition.permission_id, definition.owner, - ActionAvailability.ACTIVE, + ActionAvailability.PLANNED, ) if definition.action_id is ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE else definition diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index 979b210d9..defbf2be4 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -233,6 +233,7 @@ def test_expanded_lane_families_keep_exact_semantic_owners() -> None: assert schema_static_contracts.isdisjoint(partitioned_modules) assert { "tests/test_alembic.py", + "tests/projects/guide_compilation/test_runtime_cleanup_postgresql.py", "tests/migrations/test_contribution_export_snapshots.py", "tests/migrations/test_contribution_read_audit.py", "tests/test_database_reset.py", @@ -288,8 +289,8 @@ def test_semantic_family_membership_has_an_independent_fingerprint() -> None: "c8a0538c251f77fc1893ed8aa76feff8fa2134cf8e9b5a049f32fbabd5226277", ), "project": ( - 229, - "5b644de8f6d38d47c7bcd7f048a3497178597dc610f8da5bbe67fccd570a269a", + 228, + "774cce9097b9dc56804a5f1092b9eb38a98cd797d4b26bdca65602102a6d8c08", ), "task": ( 95, @@ -361,9 +362,9 @@ def test_project_lane_ownership_has_an_independent_fingerprint() -> None: ) + "\n" ) - assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 229 + assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 228 assert hashlib.sha256(canonical.encode()).hexdigest() == ( - "972af04cc7a86c871d97fe4b059a870e0849d9586a67eb8b523c910968f88c8a" + "7f57bcaec45288313e78610105ccae29aa22ec5de63850b9d221a2c9f83dc474" ) From 62e950ffaed260365d492e981f3c00bafd5f76d1 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 23:08:11 +0100 Subject: [PATCH 056/115] test(checkers): accept registry foreign-key truncate denial --- backend/tests/checkers/external/test_postgresql.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/backend/tests/checkers/external/test_postgresql.py b/backend/tests/checkers/external/test_postgresql.py index 1b11db589..348893df5 100644 --- a/backend/tests/checkers/external/test_postgresql.py +++ b/backend/tests/checkers/external/test_postgresql.py @@ -416,15 +416,25 @@ async def test_registry_rejects_update_delete_truncate_and_unauthorized_insert( request = registration_request(actor_profile_id=admin_access.target.id) await _register(admin_access, request) + baseline = await _snapshot() for statement in ( "update external_checker_registry_entries set capability_version='v2'", "delete from external_checker_registry_entries", - "truncate external_checker_registry_entries", ): async with db_session.get_session_factory()() as session: with pytest.raises(DBAPIError, match="external checker registry is immutable"): async with session.begin(): await session.execute(text(statement)) + assert await _snapshot() == baseline + + async with db_session.get_session_factory()() as session: + with pytest.raises(DBAPIError) as truncate_error: + async with session.begin(): + await session.execute(text("truncate external_checker_registry_entries")) + truncate_message = str(truncate_error.value) + assert "cannot truncate a table referenced in a foreign key constraint" in truncate_message + assert 'references "external_checker_registry_entries"' in truncate_message + assert await _snapshot() == baseline spec = registry_spec("post_submit") async with db_session.get_session_factory()() as session: From b232fe7830d1511de1503b25af0a12f221fd1221 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 23:08:42 +0100 Subject: [PATCH 057/115] ci: preserve existing cleanup fixture lane after diagnosis --- backend/scripts/test_lane_catalogue.py | 2 +- backend/tests/test_ci_lane_catalogue.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index b04a4ec54..5131ccf22 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -437,6 +437,7 @@ class TestLane: "tests/projects/guide_compilation/test_document_access_postgresql.py", "tests/projects/guide_compilation/test_neutral_document_tools.py", "tests/projects/guide_compilation/test_document_file_custody_postgresql.py", + "tests/projects/guide_compilation/test_runtime_cleanup_postgresql.py", "tests/projects/guide_compilation/test_document_scope_contracts.py", "tests/projects/guide_compilation/test_task_examples_postgresql.py", "tests/projects/guide_compilation/test_database_guards.py", @@ -734,7 +735,6 @@ class TestLane: "schema_contracts", ( SCHEMA_MODULE, - "tests/projects/guide_compilation/test_runtime_cleanup_postgresql.py", "tests/migrations/test_contribution_read_audit.py", "tests/migrations/test_contribution_export_snapshots.py", "tests/authorization/post_submit/test_receipt_custody.py", diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index defbf2be4..979b210d9 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -233,7 +233,6 @@ def test_expanded_lane_families_keep_exact_semantic_owners() -> None: assert schema_static_contracts.isdisjoint(partitioned_modules) assert { "tests/test_alembic.py", - "tests/projects/guide_compilation/test_runtime_cleanup_postgresql.py", "tests/migrations/test_contribution_export_snapshots.py", "tests/migrations/test_contribution_read_audit.py", "tests/test_database_reset.py", @@ -289,8 +288,8 @@ def test_semantic_family_membership_has_an_independent_fingerprint() -> None: "c8a0538c251f77fc1893ed8aa76feff8fa2134cf8e9b5a049f32fbabd5226277", ), "project": ( - 228, - "774cce9097b9dc56804a5f1092b9eb38a98cd797d4b26bdca65602102a6d8c08", + 229, + "5b644de8f6d38d47c7bcd7f048a3497178597dc610f8da5bbe67fccd570a269a", ), "task": ( 95, @@ -362,9 +361,9 @@ def test_project_lane_ownership_has_an_independent_fingerprint() -> None: ) + "\n" ) - assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 228 + assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 229 assert hashlib.sha256(canonical.encode()).hexdigest() == ( - "7f57bcaec45288313e78610105ccae29aa22ec5de63850b9d221a2c9f83dc474" + "972af04cc7a86c871d97fe4b059a870e0849d9586a67eb8b523c910968f88c8a" ) From c6b7e76d9ffc33f3b3d716fcbbd8f39445a0f10a Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 23:15:15 +0100 Subject: [PATCH 058/115] fix(tests): preserve historical and inference fixtures --- .../test_capability_growth_postgresql.py | 5 +- .../test_live_cutover_postgresql.py | 91 ++++++++++++++- .../test_runtime_cleanup_postgresql.py | 1 - .../test_revision_exhaustion_recovery.py | 104 ++++++++++-------- backend/tests/test_alembic.py | 87 ++++++++++----- .../tests/test_project_agent_resilience.py | 8 +- 6 files changed, 219 insertions(+), 77 deletions(-) diff --git a/backend/tests/projects/guide_compilation/test_capability_growth_postgresql.py b/backend/tests/projects/guide_compilation/test_capability_growth_postgresql.py index d4d4f4c4e..c54361838 100644 --- a/backend/tests/projects/guide_compilation/test_capability_growth_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_capability_growth_postgresql.py @@ -14,13 +14,16 @@ from .test_automatic_request import automatic_source as automatic_source from .runtime_fixtures import ScriptedGuideRuntime, scripted_runtime_configuration from .test_live_cutover_postgresql import ( - _delivery, scripted_document_port as scripted_document_port, + _delivery, + scripted_document_port as scripted_document_port, + scripted_model_profile as scripted_model_profile, ) from .test_capability_growth import growth_report from .test_compilation_storage_limit import boundary_reports @pytest.mark.parametrize("oversized", [False, True]) +@pytest.mark.usefixtures("scripted_model_profile") async def test_handoff_persistence_and_terminal_replay(automatic_source, monkeypatch, oversized): from app.workers import project_setup as worker diff --git a/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py b/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py index c3e94ff59..670bcab5a 100644 --- a/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py @@ -3,13 +3,19 @@ from uuid import UUID, uuid4 import pytest -from sqlalchemy import text +from sqlalchemy import select, text +from app.core.config import get_settings from app.modules.projects.api.guide_compilation import ProjectGuideCompilationDelivery from app.modules.projects.api.setup_identity import project_guide_compilation_task_id from app.modules.projects.api.guide_compilation import ProjectGuideCompilationDeliveryError +from app.modules.model_usage.api import ProjectModelProfileSpec +from app.modules.projects.models import ProjectSetupRun +from tests.actors.support import set_dev_actor +from tests.checkers_model_calls.test_model_call_contract import profile_spec from tests.committed_guide_fixtures import create_committed_document_fixture from tests.projects.client_fixtures import ( + auth_headers, project_client as project_client, project_database_env as project_database_env, ) @@ -38,6 +44,80 @@ def scripted_document_port(monkeypatch): ) +@pytest.fixture +async def scripted_model_profile(automatic_source, project_client, monkeypatch): + """Publish the exact inference profile selected by these live coordinator tests.""" + factory, _actor, setup_id, _snapshot = automatic_source + async with factory() as session: + project_id = await session.scalar( + select(ProjectSetupRun.project_id).where(ProjectSetupRun.id == str(setup_id)) + ) + assert project_id is not None + + set_dev_actor( + monkeypatch, + roles="viewer", + subject="scripted-model-operator", + issuer="flow-test", + token="project-token", + ) + admitted = await project_client.get("/api/v1/actors/me", headers=auth_headers()) + assert admitted.status_code == 200, admitted.text + operator_id = admitted.json()["actor_profile_id"] + set_dev_actor( + monkeypatch, + roles="project_manager", + subject="project-manager-subject", + issuer="flow-test", + token="project-token", + ) + grant = await project_client.post( + "/api/v1/admin-role-grants", + headers=auth_headers(), + json={ + "target_actor_profile_id": operator_id, + "role": "operator", + "scope_type": "system", + "scope_project_id": None, + "reason": "Exercise profile-selected project guide compilation", + }, + ) + assert grant.status_code == 201, grant.text + set_dev_actor( + monkeypatch, + roles="viewer", + subject="scripted-model-operator", + issuer="flow-test", + token="project-token", + ) + configuration = scripted_runtime_configuration( + get_settings(), adapter_profile_builder=lambda value: value + ) + source = profile_spec().model_dump(mode="python") + source.update( + project_id=project_id, + profile_version=1, + provider=configuration.model_provider, + api=configuration.model_api, + model_snapshot=configuration.model, + ) + spec = ProjectModelProfileSpec.model_validate(source) + published = await project_client.post( + f"/api/v1/operations/projects/{project_id}/model-profiles", + headers=auth_headers(), + json=spec.model_dump(mode="json"), + ) + assert published.status_code == 201, published.text + set_dev_actor( + monkeypatch, + roles="project_manager", + subject="project-manager-subject", + issuer="flow-test", + token="project-token", + ) + yield + + async def _delivery(factory, setup_id): async with factory() as session, session.begin(): row = ( @@ -67,6 +147,7 @@ async def _delivery(factory, setup_id): +@pytest.mark.usefixtures("scripted_model_profile") @pytest.mark.parametrize("status", ["draft_ready", "draft_ready_with_warnings", "guide_blocked"]) async def test_pending_exact_delivery_compiles_once_and_replays_finalization( automatic_source, monkeypatch, status, project_client @@ -177,6 +258,7 @@ def forbidden_configuration(_settings, *, adapter_profile_builder): assert runtime.calls == 1 +@pytest.mark.usefixtures("scripted_model_profile") @pytest.mark.parametrize("phase", ["compilation", "sufficiency", "finalization"]) async def test_compilation_phase_and_public_setup_read_share_parent_lock_order( automatic_source, project_client, project_database_env, monkeypatch, phase @@ -300,6 +382,7 @@ def forbidden_configuration(_settings, *, adapter_profile_builder): assert await session.scalar(text("select count(*) from " + table)) == 0 +@pytest.mark.usefixtures("scripted_model_profile") @pytest.mark.parametrize("outcome", ["invalid", "uncertain"]) async def test_invalid_or_uncertain_attempt_reports_durable_diagnostics_without_projection( automatic_source, monkeypatch, outcome, project_client @@ -388,6 +471,7 @@ async def test_invalid_or_uncertain_attempt_reports_durable_diagnostics_without_ assert await session.scalar(text("select count(*) from " + table)) == 0 +@pytest.mark.usefixtures("scripted_model_profile") async def test_finalized_receipt_excludes_every_recovery_shape(automatic_source, monkeypatch): from app.workers import project_setup as worker @@ -479,6 +563,7 @@ def forbidden_enqueue(**kwargs): ) +@pytest.mark.usefixtures("scripted_model_profile") async def test_publisher_acknowledgement_cannot_overwrite_worker_finalization( automatic_source, monkeypatch ): @@ -558,6 +643,7 @@ async def dispatch(): @pytest.mark.parametrize( "boundary", ["persisted", "sufficiency", "both_projections", "retained_unconfigured"] ) +@pytest.mark.usefixtures("scripted_model_profile") async def test_phase_crash_recovers_same_attempt_without_reinference( automatic_source, monkeypatch, boundary ): @@ -648,6 +734,7 @@ async def project_submission_artifact_policy(self, command): ) +@pytest.mark.usefixtures("scripted_model_profile") @pytest.mark.parametrize("phase", ["request", "fence", "sufficiency", "policy", "finalization"]) async def test_each_phase_rechecks_service_authority_and_restoration_reuses_attempt( automatic_source, monkeypatch, phase @@ -746,6 +833,7 @@ async def revoked_call(*args, **kwargs): ) +@pytest.mark.usefixtures("scripted_model_profile") async def test_concurrent_live_deliveries_share_one_provider_and_finalization( automatic_source, monkeypatch ): @@ -845,6 +933,7 @@ def forbidden_configuration(_settings, *, adapter_profile_builder): assert await session.scalar(text("select count(*) from " + table)) == 0 +@pytest.mark.usefixtures("scripted_model_profile") async def test_stale_queued_configuration_failure_is_reclaimed_and_finishes_same_delivery( automatic_source, monkeypatch ): diff --git a/backend/tests/projects/guide_compilation/test_runtime_cleanup_postgresql.py b/backend/tests/projects/guide_compilation/test_runtime_cleanup_postgresql.py index 7eaf4678d..1bf449342 100644 --- a/backend/tests/projects/guide_compilation/test_runtime_cleanup_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_runtime_cleanup_postgresql.py @@ -21,7 +21,6 @@ from .helpers import runtime_configuration RETAINED_RUNTIME = "openai_agents_sdk" -pytestmark = pytest.mark.postgres_schema_contract def _historical_configuration(): diff --git a/backend/tests/tasks/submission_dispatch/test_revision_exhaustion_recovery.py b/backend/tests/tasks/submission_dispatch/test_revision_exhaustion_recovery.py index 0a4c7f802..2d39e0631 100644 --- a/backend/tests/tasks/submission_dispatch/test_revision_exhaustion_recovery.py +++ b/backend/tests/tasks/submission_dispatch/test_revision_exhaustion_recovery.py @@ -37,7 +37,11 @@ ) from tests.tasks.submission_dispatch.test_revision_expiry import _provision_expirer from tests.tasks.test_public_submission_intake import _client -from tests.test_alembic import _alembic_config, _execute +from tests.test_alembic import ( + _alembic_config, + _execute, + _historical_artifact_model_usage_projection, +) pytestmark = pytest.mark.postgres_schema_contract @@ -88,34 +92,41 @@ async def old_source(preparer, *args, **kwargs): monkeypatch, "0047_revision_source_cap_one", ) as h: - await assert_historical_revision_guards(h.factory, "0047_revision_recovery") - h.envelope = await invoked_completion(h) - await assert_historical_revision_guards(h.factory, "0047_revision_recovery") - with monkeypatch.context() as patch: - patch.setattr(TaskRoutingSourcePreparer, "prepare", old_source) - patch.setattr("app.modules.tasks.post_submit_routing.outcome.prepare_revision", insert_0047_preparation) - patch.setattr("app.modules.tasks.revision.service.head_for_source", _0047_head) - patch.setattr("app.modules.tasks.submission_context.head_for_source", _0047_head) - initial = await _apply(h) - async with h.factory() as session: - first = await _0047_head(session, initial.routing_manifest_id) - h.revision_preparation_id = first.id - h.revision_preparation_sha256 = first.preparation_sha256 - h.data = revision_archive(h.data) - prepared, admission = await _ready_replacement(h, h.data) - h.created = await _create(h, _creation_request(h, prepared, admission)) - assert h.created.submission_version == 2 - retained = await _fail_current_submission(h, terminal=True) - async with h.factory() as session: - blocked = await _0047_head(session, retained.routing_manifest_id) - assert (blocked.outcome, blocked.blocked_reason) == ("blocked", "revision_limit_reached") - assert blocked.next_revision_round == 2 and blocked.max_revision_rounds == 1 - before = await retained_revision_rows(session, retained.routing_manifest_id) - assert await session.scalar(text(""" - SELECT count(*) FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid - JOIN pg_namespace n ON n.oid=c.relnamespace - WHERE n.nspname='public' AND NOT t.tgisinternal AND t.tgenabled='D' - """)) == 0 + async with _historical_artifact_model_usage_projection(database_url): + await assert_historical_revision_guards(h.factory, "0047_revision_recovery") + h.envelope = await invoked_completion(h) + await assert_historical_revision_guards(h.factory, "0047_revision_recovery") + with monkeypatch.context() as patch: + patch.setattr(TaskRoutingSourcePreparer, "prepare", old_source) + patch.setattr( + "app.modules.tasks.post_submit_routing.outcome.prepare_revision", + insert_0047_preparation, + ) + patch.setattr("app.modules.tasks.revision.service.head_for_source", _0047_head) + patch.setattr("app.modules.tasks.submission_context.head_for_source", _0047_head) + initial = await _apply(h) + async with h.factory() as session: + first = await _0047_head(session, initial.routing_manifest_id) + h.revision_preparation_id = first.id + h.revision_preparation_sha256 = first.preparation_sha256 + h.data = revision_archive(h.data) + prepared, admission = await _ready_replacement(h, h.data) + h.created = await _create(h, _creation_request(h, prepared, admission)) + assert h.created.submission_version == 2 + retained = await _fail_current_submission(h, terminal=True) + async with h.factory() as session: + blocked = await _0047_head(session, retained.routing_manifest_id) + assert (blocked.outcome, blocked.blocked_reason) == ( + "blocked", + "revision_limit_reached", + ) + assert blocked.next_revision_round == 2 and blocked.max_revision_rounds == 1 + before = await retained_revision_rows(session, retained.routing_manifest_id) + assert await session.scalar(text(""" + SELECT count(*) FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid + JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname='public' AND NOT t.tgisinternal AND t.tgenabled='D' + """)) == 0 shape.undo() await leave_predecessor_owner_shape(_execute, database_url) await asyncio.to_thread(command.upgrade, _alembic_config(), current_schema_revision()) @@ -236,20 +247,27 @@ async def test_upgrade_backfills_assignments_around_a_retained_exhausted_assignm monkeypatch, "0048_revision_source_cap_zero", ) as h: - await assert_historical_revision_guards(h.factory, "0048_revision_exhaustion") - h.envelope = await invoked_completion(h) - await assert_historical_revision_guards(h.factory, "0048_revision_exhaustion") - exhausted = await _apply(h) - async with h.factory() as session: - receipt = await session.scalar(select(TaskRevisionExhaustion).where( - TaskRevisionExhaustion.routing_manifest_id == exhausted.routing_manifest_id, - )) - assert receipt is not None - before = await session.scalar(text( - "SELECT to_jsonb(a)-ARRAY['started_at','updated_at'] " - "FROM public.task_assignments a WHERE id=:id" - ), {"id": receipt.assignment_id}) - assert before["status"] == "revision_exhausted" + async with _historical_artifact_model_usage_projection(database_url): + await assert_historical_revision_guards(h.factory, "0048_revision_exhaustion") + h.envelope = await invoked_completion(h) + await assert_historical_revision_guards(h.factory, "0048_revision_exhaustion") + exhausted = await _apply(h) + async with h.factory() as session: + receipt = await session.scalar( + select(TaskRevisionExhaustion).where( + TaskRevisionExhaustion.routing_manifest_id + == exhausted.routing_manifest_id, + ) + ) + assert receipt is not None + before = await session.scalar( + text( + "SELECT to_jsonb(a)-ARRAY['started_at','updated_at'] " + "FROM public.task_assignments a WHERE id=:id" + ), + {"id": receipt.assignment_id}, + ) + assert before["status"] == "revision_exhausted" shape.undo() await leave_predecessor_owner_shape(_execute, database_url) await asyncio.to_thread(command.upgrade, _alembic_config(), current_schema_revision()) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index ba71f01fb..7d4570443 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from contextlib import asynccontextmanager import hashlib import json from pathlib import Path @@ -74,6 +75,33 @@ async def _execute(database_url: str, statement: str, *arguments: object) -> Non await connection.close() +@asynccontextmanager +async def _historical_artifact_model_usage_projection(database_url: str): + """Expose nullable 0061 ORM columns while arranging older ART rows.""" + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + columns = ( + ("artifact_put_attempts", "model_call_attempt_id"), + ("artifact_operation_receipts", "model_call_attempt_id"), + ) + installed: list[tuple[str, str]] = [] + try: + for table, column in columns: + await connection.execute(f"ALTER TABLE {table} ADD COLUMN {column} uuid") + installed.append((table, column)) + yield + finally: + unexpected = { + f"{table}.{column}": await connection.fetchval( + f"SELECT count(*) FROM {table} WHERE {column} IS NOT NULL" + ) + for table, column in installed + } + for table, column in reversed(installed): + await connection.execute(f"ALTER TABLE {table} DROP COLUMN {column}") + await connection.close() + assert all(count == 0 for count in unexpected.values()), unexpected + + async def _seed_retained_guide_runtime_rows( database_url: str, *, status: str, include_unknown: bool = False ) -> dict[str, object]: @@ -86,7 +114,8 @@ async def _seed_retained_guide_runtime_rows( _historical_configuration, ) - values = await seed_database(database_url) + async with _historical_artifact_model_usage_projection(database_url): + values = await seed_database(database_url) compilation = context(values) attempt_identity = identity(compilation) document = compilation.material.documents[0] @@ -626,20 +655,7 @@ def test_neutral_runtime_upgrade_preserves_retained_provider_facts( isolated_database_env: str, migration_lock ) -> None: """0059 labels historical access without inventing parser evidence or losing IDs.""" - config = _alembic_config() - with migration_lock(): - asyncio.run( - _execute(isolated_database_env, "drop schema public cascade; create schema public") - ) - command.upgrade(config, "0058_compensation_unit_create") - seeded = asyncio.run( - _seed_retained_guide_runtime_rows( - isolated_database_env, status="compilation_provider_uncertain" - ) - ) - command.upgrade(config, HEAD_REVISION) - - async def retained(): + async def retained(seeded): connection = await asyncpg.connect(isolated_database_env.replace("+asyncpg", "")) try: attempt = await connection.fetchrow( @@ -661,7 +677,19 @@ async def retained(): finally: await connection.close() - attempt, allocations, access = asyncio.run(retained()) + config = _alembic_config() + with migration_lock(): + asyncio.run( + _execute(isolated_database_env, "drop schema public cascade; create schema public") + ) + command.upgrade(config, "0058_compensation_unit_create") + seeded = asyncio.run( + _seed_retained_guide_runtime_rows( + isolated_database_env, status="compilation_provider_uncertain" + ) + ) + command.upgrade(config, HEAD_REVISION) + attempt, allocations, access = asyncio.run(retained(seeded)) assert json.loads(attempt["runtime_configuration"]) == seeded["configuration"] assert attempt["runtime_configuration_hash"] == seeded["configuration_hash"] assert attempt["status"] == "compilation_provider_uncertain" @@ -687,6 +715,18 @@ def test_neutral_runtime_upgrade_refuses_old_predispatch_inference( """An old reserved inference attempt cannot cross into a runtime that cannot resume it.""" from sqlalchemy.exc import DBAPIError + async def retained_attempt_exists(attempt_id) -> bool: + connection = await asyncpg.connect(isolated_database_env.replace("+asyncpg", "")) + try: + return bool( + await connection.fetchval( + "select exists(select 1 from project_guide_compilation_attempts where id=$1)", + attempt_id, + ) + ) + finally: + await connection.close() + config = _alembic_config() with migration_lock(): asyncio.run( @@ -703,22 +743,11 @@ def test_neutral_runtime_upgrade_refuses_old_predispatch_inference( ): command.upgrade(config, HEAD_REVISION) after = asyncio.run(_database_snapshot(isolated_database_env)) + retained = asyncio.run(retained_attempt_exists(seeded["attempt_id"])) assert before == after assert after["versions"] == ["0058_compensation_unit_create"] - async def retained_attempt_exists() -> bool: - connection = await asyncpg.connect(isolated_database_env.replace("+asyncpg", "")) - try: - return bool( - await connection.fetchval( - "select exists(select 1 from project_guide_compilation_attempts where id=$1)", - seeded["attempt_id"], - ) - ) - finally: - await connection.close() - - assert asyncio.run(retained_attempt_exists()) + assert retained async def test_roles_anywhere_upgrade_preserves_old_proof_and_refuses_downgrade( diff --git a/backend/tests/test_project_agent_resilience.py b/backend/tests/test_project_agent_resilience.py index 4dfb43b10..52d351d6b 100644 --- a/backend/tests/test_project_agent_resilience.py +++ b/backend/tests/test_project_agent_resilience.py @@ -323,13 +323,17 @@ async def test_open_circuit_prevents_product_fence_and_resource_allocation(monke monkeypatch.setenv("OPENROUTER_API_KEY", "test-only") monkeypatch.setattr(module, "project_guide_compilation_prompt_bytes", lambda _: b"manifest") calls = [] + runtime_configuration = configuration() class Backend: async def load(self, attempt): - return SimpleNamespace(classification=CompilationRecoveryClassification.RESERVED) + return SimpleNamespace( + classification=CompilationRecoveryClassification.RESERVED, + runtime_configuration=runtime_configuration, + ) async def context(self, state): - return SimpleNamespace(runtime_configuration=configuration()) + return SimpleNamespace(runtime_configuration=runtime_configuration) async def fence(self, state): calls.append("fence") From 34dc5096d1369b8fb2c7044219a507011d4f5f8b Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 23:17:02 +0100 Subject: [PATCH 059/115] fix(test): provide setup model capability to SDK parser proof --- .../test_hidden_orchestrator_postgresql.py | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/backend/tests/projects/guide_compilation/test_hidden_orchestrator_postgresql.py b/backend/tests/projects/guide_compilation/test_hidden_orchestrator_postgresql.py index f3c951587..276b6d9da 100644 --- a/backend/tests/projects/guide_compilation/test_hidden_orchestrator_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_hidden_orchestrator_postgresql.py @@ -127,7 +127,7 @@ def _backend(factory): ) -def _port(factory, runtime, *, checker_capabilities=None): +def _port(factory, runtime, *, checker_capabilities=None, model_session_factory=None): return project_guide_compilation_execution_port( factory, material_factory=guide_document_manifest_port, @@ -135,6 +135,7 @@ def _port(factory, runtime, *, checker_capabilities=None): checker_capabilities=checker_capabilities or capability_projection(), authorization_context=_fixed_service_authorization, runtime_factory=lambda configuration: runtime, + model_session_factory=model_session_factory, ) @@ -820,7 +821,9 @@ async def test_sdk_parser_rejection_persists_terminal_without_reinvocation( from types import SimpleNamespace from agents import Runner from app.adapters.project_agents.openai_agent_sdk import OpenAIAgentSdkProjectGuideRuntime - from tests.test_agent_runtime import _provider_payload + from app.core.hashing import canonical_json_hash + from app.core.identifiers import new_record_id + from tests.test_agent_runtime import _TestModelSession, _provider_payload from agents import _debug @@ -845,13 +848,32 @@ async def run(agent, prompt, **kwargs): monkeypatch.setattr(Runner, "run", run) + configuration = type(runtime_configuration()).model_validate( + runtime_configuration().model_dump(mode="json") + | { + "model_profile_id": new_record_id(), + "model_profile_version": 1, + "model_profile_digest": canonical_json_hash({"profile": "sdk-parser-test"}), + } + ) + + @asynccontextmanager + async def model_session(subject, selected_configuration): + assert subject is not None + assert selected_configuration == configuration + yield _TestModelSession() + values = await seed_database(clean_postgres_database) - requested = await _authorized_attempt(clean_postgres_database, values) + requested = await _authorized_attempt(clean_postgres_database, values, configuration) engine = create_async_engine(clean_postgres_database) factory = async_sessionmaker(engine, expire_on_commit=False) command = ProjectGuideCompilationExecutionCommand(attempt_id=requested.attempt_id) try: - port = _port(factory, OpenAIAgentSdkProjectGuideRuntime(runtime_configuration())) + port = _port( + factory, + OpenAIAgentSdkProjectGuideRuntime(configuration), + model_session_factory=model_session, + ) first = await port.execute(command) assert ( first.classification is ProjectGuideCompilationExecutionClassification.INVALID_TERMINAL From 60dc387d7b7af0b4327154a31df66cfc97f80a13 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 23:21:31 +0100 Subject: [PATCH 060/115] refactor(tests): isolate scripted model profile fixture --- .../guide_compilation/runtime_fixtures.py | 83 +++++++++++++++++++ .../test_capability_growth_postgresql.py | 7 +- .../test_live_cutover_postgresql.py | 83 +------------------ 3 files changed, 90 insertions(+), 83 deletions(-) diff --git a/backend/tests/projects/guide_compilation/runtime_fixtures.py b/backend/tests/projects/guide_compilation/runtime_fixtures.py index ea26326b9..96bc8a08b 100644 --- a/backend/tests/projects/guide_compilation/runtime_fixtures.py +++ b/backend/tests/projects/guide_compilation/runtime_fixtures.py @@ -9,6 +9,15 @@ from io import BytesIO from pathlib import Path +import pytest +from sqlalchemy import select + +from app.core.config import get_settings +from app.modules.model_usage.api import ProjectModelProfileSpec +from app.modules.projects.models import ProjectSetupRun +from tests.actors.support import set_dev_actor +from tests.checkers_model_calls.test_model_call_contract import profile_spec +from tests.projects.client_fixtures import auth_headers from app.adapters.guide_document_tools import ( GUIDE_DOCUMENT_PARSER_CAPABILITY_KEY, GUIDE_DOCUMENT_PARSER_PROVIDER_KEY, @@ -93,6 +102,80 @@ def scripted_document_access_runtime( return document_access(attempt_id, manifest, configuration) +@pytest.fixture +async def scripted_model_profile(automatic_source, project_client, monkeypatch): + """Publish the exact inference profile selected by live coordinator tests.""" + factory, _actor, setup_id, _snapshot = automatic_source + async with factory() as session: + project_id = await session.scalar( + select(ProjectSetupRun.project_id).where(ProjectSetupRun.id == str(setup_id)) + ) + assert project_id is not None + + set_dev_actor( + monkeypatch, + roles="viewer", + subject="scripted-model-operator", + issuer="flow-test", + token="project-token", + ) + admitted = await project_client.get("/api/v1/actors/me", headers=auth_headers()) + assert admitted.status_code == 200, admitted.text + operator_id = admitted.json()["actor_profile_id"] + set_dev_actor( + monkeypatch, + roles="project_manager", + subject="project-manager-subject", + issuer="flow-test", + token="project-token", + ) + grant = await project_client.post( + "/api/v1/admin-role-grants", + headers=auth_headers(), + json={ + "target_actor_profile_id": operator_id, + "role": "operator", + "scope_type": "system", + "scope_project_id": None, + "reason": "Exercise profile-selected project guide compilation", + }, + ) + assert grant.status_code == 201, grant.text + set_dev_actor( + monkeypatch, + roles="viewer", + subject="scripted-model-operator", + issuer="flow-test", + token="project-token", + ) + configuration = scripted_runtime_configuration( + get_settings(), adapter_profile_builder=lambda value: value + ) + source = profile_spec().model_dump(mode="python") + source.update( + project_id=project_id, + profile_version=1, + provider=configuration.model_provider, + api=configuration.model_api, + model_snapshot=configuration.model, + ) + spec = ProjectModelProfileSpec.model_validate(source) + published = await project_client.post( + f"/api/v1/operations/projects/{project_id}/model-profiles", + headers=auth_headers(), + json=spec.model_dump(mode="json"), + ) + assert published.status_code == 201, published.text + set_dev_actor( + monkeypatch, + roles="project_manager", + subject="project-manager-subject", + issuer="flow-test", + token="project-token", + ) + yield + + def document_tools(session_factory, *, document_access_factory=None): """Bind the test grant/parser to the production PROJECTS receipt owner.""" return project_guide_document_tools_factory( diff --git a/backend/tests/projects/guide_compilation/test_capability_growth_postgresql.py b/backend/tests/projects/guide_compilation/test_capability_growth_postgresql.py index c54361838..0ba8bbc91 100644 --- a/backend/tests/projects/guide_compilation/test_capability_growth_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_capability_growth_postgresql.py @@ -12,11 +12,14 @@ project_client as project_client, project_database_env as project_database_env, ) from .test_automatic_request import automatic_source as automatic_source -from .runtime_fixtures import ScriptedGuideRuntime, scripted_runtime_configuration +from .runtime_fixtures import ( + ScriptedGuideRuntime, + scripted_model_profile, # noqa: F401 - registered pytest fixture + scripted_runtime_configuration, +) from .test_live_cutover_postgresql import ( _delivery, scripted_document_port as scripted_document_port, - scripted_model_profile as scripted_model_profile, ) from .test_capability_growth import growth_report from .test_compilation_storage_limit import boundary_reports diff --git a/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py b/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py index 670bcab5a..4007144b4 100644 --- a/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py @@ -3,19 +3,13 @@ from uuid import UUID, uuid4 import pytest -from sqlalchemy import select, text +from sqlalchemy import text -from app.core.config import get_settings from app.modules.projects.api.guide_compilation import ProjectGuideCompilationDelivery from app.modules.projects.api.setup_identity import project_guide_compilation_task_id from app.modules.projects.api.guide_compilation import ProjectGuideCompilationDeliveryError -from app.modules.model_usage.api import ProjectModelProfileSpec -from app.modules.projects.models import ProjectSetupRun -from tests.actors.support import set_dev_actor -from tests.checkers_model_calls.test_model_call_contract import profile_spec from tests.committed_guide_fixtures import create_committed_document_fixture from tests.projects.client_fixtures import ( - auth_headers, project_client as project_client, project_database_env as project_database_env, ) @@ -25,6 +19,7 @@ ScriptedGuideRuntime, scripted_document_access_runtime, scripted_guide_document_parser_factory, + scripted_model_profile, # noqa: F401 - registered pytest fixture scripted_runtime_configuration, ) @@ -44,80 +39,6 @@ def scripted_document_port(monkeypatch): ) -@pytest.fixture -async def scripted_model_profile(automatic_source, project_client, monkeypatch): - """Publish the exact inference profile selected by these live coordinator tests.""" - factory, _actor, setup_id, _snapshot = automatic_source - async with factory() as session: - project_id = await session.scalar( - select(ProjectSetupRun.project_id).where(ProjectSetupRun.id == str(setup_id)) - ) - assert project_id is not None - - set_dev_actor( - monkeypatch, - roles="viewer", - subject="scripted-model-operator", - issuer="flow-test", - token="project-token", - ) - admitted = await project_client.get("/api/v1/actors/me", headers=auth_headers()) - assert admitted.status_code == 200, admitted.text - operator_id = admitted.json()["actor_profile_id"] - set_dev_actor( - monkeypatch, - roles="project_manager", - subject="project-manager-subject", - issuer="flow-test", - token="project-token", - ) - grant = await project_client.post( - "/api/v1/admin-role-grants", - headers=auth_headers(), - json={ - "target_actor_profile_id": operator_id, - "role": "operator", - "scope_type": "system", - "scope_project_id": None, - "reason": "Exercise profile-selected project guide compilation", - }, - ) - assert grant.status_code == 201, grant.text - set_dev_actor( - monkeypatch, - roles="viewer", - subject="scripted-model-operator", - issuer="flow-test", - token="project-token", - ) - configuration = scripted_runtime_configuration( - get_settings(), adapter_profile_builder=lambda value: value - ) - source = profile_spec().model_dump(mode="python") - source.update( - project_id=project_id, - profile_version=1, - provider=configuration.model_provider, - api=configuration.model_api, - model_snapshot=configuration.model, - ) - spec = ProjectModelProfileSpec.model_validate(source) - published = await project_client.post( - f"/api/v1/operations/projects/{project_id}/model-profiles", - headers=auth_headers(), - json=spec.model_dump(mode="json"), - ) - assert published.status_code == 201, published.text - set_dev_actor( - monkeypatch, - roles="project_manager", - subject="project-manager-subject", - issuer="flow-test", - token="project-token", - ) - yield - - async def _delivery(factory, setup_id): async with factory() as session, session.begin(): row = ( From 404aa9e7829d362c4110d6e218084e6a544c7bec Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 23:22:30 +0100 Subject: [PATCH 061/115] fix(model-usage): bind settlement and recovery custody --- .commitrail/changes/checker-model-runtime.md | 36 +++ backend/alembic/versions/0061_model_usage.py | 109 +++++++ backend/app/modules/model_usage/grants.py | 4 +- backend/app/modules/model_usage/inspection.py | 4 +- backend/app/modules/model_usage/models.py | 26 +- .../app/modules/model_usage/output_custody.py | 6 +- backend/app/modules/model_usage/repository.py | 4 +- backend/app/modules/model_usage/service.py | 4 + backend/app/modules/model_usage/setup.py | 34 ++- ...identifier_generation_classifications.json | 4 + backend/scripts/identifier_inventory.py | 100 ++++--- .../tests/checker_output_admission_helpers.py | 20 +- .../model_usage_pg_support.py | 118 ++++++++ .../test_composed_execution.py | 54 ++-- ...test_model_call_transactions_postgresql.py | 275 ++++++++++-------- ...test_model_invocation_grants_postgresql.py | 27 +- .../test_model_shared_setup_postgresql.py | 258 +++++++++++++++- backend/tests/conftest.py | 2 +- backend/tests/test_identifier_schema.py | 8 +- docs/architecture_data_model.md | 2 +- docs/operations_checker_models.md | 9 +- 21 files changed, 854 insertions(+), 250 deletions(-) create mode 100644 backend/tests/checkers_model_calls/model_usage_pg_support.py diff --git a/.commitrail/changes/checker-model-runtime.md b/.commitrail/changes/checker-model-runtime.md index 48c0d31cd..f7472b721 100644 --- a/.commitrail/changes/checker-model-runtime.md +++ b/.commitrail/changes/checker-model-runtime.md @@ -135,6 +135,12 @@ retain their reservation; absence of a provider ID is not proof of no effect. Known provider IDs may be retrieved without another create. Cached completion is immutable and survives process, lease and Operator retries. +Setup requests disable provider-side storage. A known provider ID is therefore a +durable observation, not by itself a recovery source: if the process is lost before +response ART custody, the exact effect remains uncertain and keeps its reservation. +Only retained verified response evidence or an authenticated provider/no-effect +proof may settle or release it; an Operator read cannot manufacture certainty. + Keep the existing CheckerRun lifecycle. A run may finish `infrastructure_failed` when its model response is unavailable, while the independent paid call remains `dispatching` or `uncertain`. Operator retry attaches the same slot; it may only @@ -281,6 +287,36 @@ active paid-call ART custody, lists all nine model permission/action mappings an describes stable effect records. Existing normalized checker outcomes and true/false review-policy routing remain unchanged. +The current security repair makes setup `known_failed` completion record the +required settlement authority and timestamp before the terminal transition. A +real PostgreSQL regression proves one provider dispatch, exact settled cost, +zero remaining reservation and immutable restart replay. Checker recovery rows +now retain canonical execution/effect facts, and the `0061_model_usage` guard +independently verifies exact project, Task, assignment, Submission, registry, +run, lease, generation, effect, service actor, action and resource digest. The +valid fresh retry successor and independent substitutions of every one of those +facts passed together with the setup settlement case (`2 passed` in 14.76s). + +The same repair closes repository metadata drift: project budget accounts and +grant-call bindings are explicitly classified natural/composite identities, +all MODEL foreign keys use the referenced native UUID representation, and the +ORM declares the budget configuration uniqueness required by the account's +composite foreign key. Native UUID metadata tests pass `5/5`; the complete +static identifier inventory passes `24/24`. The first combined canonical 0061 +run installed every migration through `0061_model_usage` and emitted six passing +tests, but its final ART case exceeded the deliberately bounded 240-second child +limit. Cleanup completed and no full-run pass is claimed from that attempt; +split exact-head PostgreSQL replay and retained-data upgrade remain pending. + +Hosted configuration proof at `9a57c8831` passes 31 focused tests. An actual +parent Rust relay image built from `04bbac112` passed a controlled smoke with no +network, read-only input/root filesystem, only the invocation socket exposed, +the global grant concealed, and owned image/container cleanup confirmed. This +does not qualify hosted deployment or the composed gVisor runtime. The final +static API/lane run passes 59 tests, the CLI permission contract passes 23, and +the actual MCP permission payload passes 231; these are controlled local proofs, +not live provider or deployment evidence. + Plan review found that existing ART output slots are empty and run/lease-bound; the stable call evidence reservation above is required. It also required exact profile selection/readiness checks rather than merely hashing opaque config. diff --git a/backend/alembic/versions/0061_model_usage.py b/backend/alembic/versions/0061_model_usage.py index e8f20b642..9670c3cd8 100644 --- a/backend/alembic/versions/0061_model_usage.py +++ b/backend/alembic/versions/0061_model_usage.py @@ -1499,12 +1499,16 @@ def downgrade() -> None: checker_run_id uuid NOT NULL, worker_lease_id uuid NOT NULL, worker_lease_generation integer NOT NULL, + execution_facts jsonb NOT NULL, + effect_facts jsonb NOT NULL, authorization_evidence_id uuid NOT NULL, created_at timestamptz DEFAULT clock_timestamp() NOT NULL, CONSTRAINT pk_checker_model_usage_recoveries PRIMARY KEY(id), CONSTRAINT ck_checker_model_usage_recoveries_id_uuid7 CHECK ( (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), CONSTRAINT ck_checker_model_usage_recoveries_generation CHECK(worker_lease_generation>0), + CONSTRAINT ck_checker_model_usage_recoveries_facts CHECK( + jsonb_typeof(execution_facts)='object' AND jsonb_typeof(effect_facts)='object'), CONSTRAINT uq_checker_model_usage_recoveries_attachment UNIQUE( attempt_id,checker_run_id,worker_lease_id,worker_lease_generation), CONSTRAINT uq_checker_model_usage_recoveries_authority UNIQUE(authorization_evidence_id), @@ -2323,10 +2327,115 @@ def downgrade() -> None: r""" CREATE FUNCTION public.guard_checker_model_recovery() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE effect public.model_usage_effects%ROWTYPE; +DECLARE slot public.checker_model_usage_slots%ROWTYPE; +DECLARE run public.checker_runs%ROWTYPE; +DECLARE authority public.audit_events%ROWTYPE; +DECLARE actor public.actor_profiles%ROWTYPE; +DECLARE task_project uuid; +DECLARE assignment_task uuid; +DECLARE assignment_project uuid; +DECLARE submission_task uuid; +DECLARE expected_slot jsonb; +DECLARE expected_effect jsonb; +DECLARE expected_digest varchar; BEGIN IF TG_OP<>'INSERT' THEN RAISE EXCEPTION 'checker model recovery attachments are immutable' USING ERRCODE='55000'; END IF; + SELECT * INTO effect FROM public.model_usage_effects + WHERE id=NEW.attempt_id FOR SHARE; + SELECT * INTO slot FROM public.checker_model_usage_slots + WHERE id=effect.checker_slot_id FOR SHARE; + SELECT * INTO run FROM public.checker_runs + WHERE id=NEW.checker_run_id FOR SHARE; + SELECT * INTO authority FROM public.audit_events + WHERE id=NEW.authorization_evidence_id FOR SHARE; + SELECT * INTO actor FROM public.actor_profiles + WHERE id=authority.actor_id::uuid FOR SHARE; + SELECT project_id INTO task_project FROM public.workstream_tasks + WHERE id=run.task_id FOR SHARE; + SELECT task_id,project_id INTO assignment_task,assignment_project + FROM public.task_assignments WHERE id=slot.assignment_id FOR SHARE; + SELECT task_id INTO submission_task FROM public.submissions + WHERE id=slot.submission_id AND version=slot.submission_version FOR SHARE; + + expected_slot:=jsonb_build_object( + 'project_id',slot.project_id,'assignment_id',slot.assignment_id, + 'submission_id',slot.submission_id,'submission_version',slot.submission_version, + 'requirement_id',slot.requirement_id, + 'registry_entry_digest',slot.registry_entry_digest, + 'configuration_sha256',slot.configuration_sha256, + 'profile_id',slot.profile_id,'profile_version',slot.profile_version, + 'profile_digest',slot.profile_digest,'slot_index',slot.slot_index); + expected_effect:=jsonb_build_object( + 'project_id',slot.project_id,'task_id',run.task_id,'slot_id',slot.id, + 'slot_key',expected_slot,'attempt_id',effect.id, + 'internal_call_request_id',effect.internal_call_request_id); + expected_digest:=public.model_usage_json_digest(jsonb_build_object( + 'resource_context',jsonb_build_object( + 'resource_type','checker_model_call','resource_id',effect.id, + 'scope_project_id',slot.project_id,'action_id','checker.model_call.recover', + 'facts',jsonb_build_object( + 'execution',NEW.execution_facts,'effect',NEW.effect_facts), + 'facts_digest',public.model_usage_json_digest(jsonb_build_object( + 'action','checker.model_call.recover','facts',jsonb_build_object( + 'execution',NEW.execution_facts,'effect',NEW.effect_facts)))))); + + IF jsonb_typeof(NEW.execution_facts)<>'object' OR + (SELECT count(*) FROM jsonb_object_keys(NEW.execution_facts))<>11 OR + NOT NEW.execution_facts ?& ARRAY[ + 'project_id','task_id','assignment_id','submission_id','submission_version', + 'evaluation_request_id','evaluation_generation','checker_run_id', + 'worker_lease_id','worker_lease_generation','worker_lease_expires_at'] OR + effect.id IS NULL OR slot.id IS NULL OR run.id IS NULL OR + effect.setup_slot_id IS NOT NULL OR + effect.state NOT IN ('dispatching','uncertain','completed') OR + effect.provider_response_id IS NULL OR + NEW.effect_facts IS DISTINCT FROM expected_effect OR + NEW.attempt_id IS DISTINCT FROM (NEW.effect_facts->>'attempt_id')::uuid OR + NEW.checker_run_id IS DISTINCT FROM (NEW.execution_facts->>'checker_run_id')::uuid OR + NEW.worker_lease_id IS DISTINCT FROM (NEW.execution_facts->>'worker_lease_id')::uuid OR + NEW.worker_lease_generation IS DISTINCT FROM + (NEW.execution_facts->>'worker_lease_generation')::integer OR + (NEW.execution_facts->>'project_id')::uuid IS DISTINCT FROM slot.project_id OR + (NEW.execution_facts->>'task_id')::uuid IS DISTINCT FROM run.task_id OR + (NEW.execution_facts->>'assignment_id')::uuid IS DISTINCT FROM slot.assignment_id OR + (NEW.execution_facts->>'submission_id')::uuid IS DISTINCT FROM slot.submission_id OR + (NEW.execution_facts->>'submission_version')::integer IS DISTINCT FROM + slot.submission_version OR + (NEW.execution_facts->>'evaluation_request_id')::uuid IS DISTINCT FROM + run.evaluation_request_id OR + (NEW.execution_facts->>'evaluation_generation')::integer IS DISTINCT FROM + run.evaluation_generation OR + (NEW.execution_facts->>'worker_lease_expires_at')::timestamptz IS DISTINCT FROM + run.worker_lease_expires_at OR + run.project_id IS DISTINCT FROM slot.project_id OR + task_project IS DISTINCT FROM slot.project_id OR + assignment_task IS DISTINCT FROM run.task_id OR + assignment_project IS DISTINCT FROM slot.project_id OR + submission_task IS DISTINCT FROM run.task_id OR + (run.request_json::jsonb->>'assignment_id')::uuid IS DISTINCT FROM slot.assignment_id OR + run.submission_id IS DISTINCT FROM slot.submission_id OR + run.submission_version IS DISTINCT FROM slot.submission_version OR + run.worker_lease_id IS DISTINCT FROM NEW.worker_lease_id OR + run.worker_lease_generation IS DISTINCT FROM NEW.worker_lease_generation OR + run.status IS DISTINCT FROM 'running' OR run.phase IS DISTINCT FROM 'post_submission' THEN + RAISE EXCEPTION 'checker model recovery ancestry is invalid' USING ERRCODE='23514'; + END IF; + IF authority.id IS NULL OR actor.id IS NULL OR + authority.event_domain IS DISTINCT FROM 'authority' OR + authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + authority.action_id IS DISTINCT FROM 'checker.model_call.recover' OR + authority.permission_id IS DISTINCT FROM 'checker.model_call.recover' OR + authority.denial_code IS NOT NULL OR + authority.after_facts::jsonb IS DISTINCT FROM jsonb_build_object( + 'allowed',true,'resource_context_digest',expected_digest) OR + actor.actor_kind IS DISTINCT FROM 'service' OR actor.status IS DISTINCT FROM 'active' OR + actor.service_identity IS DISTINCT FROM 'workstream.checker.post_submit' THEN + RAISE EXCEPTION 'checker model recovery authority is invalid' USING ERRCODE='23514'; + END IF; RETURN NEW; END $$ """, diff --git a/backend/app/modules/model_usage/grants.py b/backend/app/modules/model_usage/grants.py index 7144d8be1..7fa3ee13b 100644 --- a/backend/app/modules/model_usage/grants.py +++ b/backend/app/modules/model_usage/grants.py @@ -88,7 +88,7 @@ async def issue( token_hash=token_hash, project_id=str(execution.project_id), checker_run_id=str(execution.checker_run_id), - registry_entry_id=str(registry_entry_id), + registry_entry_id=registry_entry_id, execution_facts=execution.model_dump(mode="json"), paid_slot=slot.model_dump(mode="json"), external_request_digest=external_request_digest, @@ -129,7 +129,7 @@ async def resolve(self, token: str, request_digest: str) -> ModelInvocationGrant external_request_digest=request_digest, execution=execution, slot=slot, - registry_entry_id=UUID(row.registry_entry_id), + registry_entry_id=UUID(str(row.registry_entry_id)), ) async def consume_prepared( diff --git a/backend/app/modules/model_usage/inspection.py b/backend/app/modules/model_usage/inspection.py index cce742752..52126a2c0 100644 --- a/backend/app/modules/model_usage/inspection.py +++ b/backend/app/modules/model_usage/inspection.py @@ -25,9 +25,9 @@ from app.modules.model_usage.repository import require_model_call_transaction -def _uuid(value: str) -> UUID: +def _uuid(value: str | UUID) -> UUID: """Decode a stored canonical record identifier.""" - return UUID(value) + return UUID(str(value)) def _checker_key(row: CheckerModelUsageSlot) -> ModelPaidSlotKey: diff --git a/backend/app/modules/model_usage/models.py b/backend/app/modules/model_usage/models.py index c0e63bdaa..89f38a07c 100644 --- a/backend/app/modules/model_usage/models.py +++ b/backend/app/modules/model_usage/models.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime +from uuid import UUID from sqlalchemy import ( BigInteger, @@ -186,6 +187,11 @@ class ModelUsageBudgetConfiguration(Base): "configuration_digest", name="model_usage_budget_configurations_attempt_identity", ), + UniqueConstraint( + "id", + "project_id", + name="model_usage_budget_configurations_id_project", + ), UniqueConstraint("configuration_digest", name="model_usage_budget_configurations_digest"), UniqueConstraint("operation_id", name="model_usage_budget_configurations_operation"), UniqueConstraint( @@ -437,7 +443,7 @@ class SetupModelUsageEnvelope(Base): ) id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) - compilation_attempt_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + compilation_attempt_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) project_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) guide_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) source_snapshot_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) @@ -521,7 +527,7 @@ class SetupModelUsageSlot(Base): ForeignKey("setup_model_usage_envelopes.id", ondelete="RESTRICT"), nullable=False, ) - compilation_attempt_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + compilation_attempt_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) project_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) guide_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) source_snapshot_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) @@ -551,9 +557,7 @@ class ModelUsageEffect(Base): UniqueConstraint( "setup_slot_id", "attempt_number", name="model_usage_effects_setup_number" ), - UniqueConstraint( - "internal_call_request_id", name="model_usage_effects_internal_request" - ), + UniqueConstraint("internal_call_request_id", name="model_usage_effects_internal_request"), UniqueConstraint( "prepared_authorization_evidence_id", name="model_usage_effects_prepare_authority", @@ -694,6 +698,10 @@ class CheckerModelUsageRecovery(Base): "authorization_evidence_id", name="checker_model_usage_recoveries_authority" ), CheckConstraint("worker_lease_generation > 0", name="lease_generation"), + CheckConstraint( + "jsonb_typeof(execution_facts) = 'object' and jsonb_typeof(effect_facts) = 'object'", + name="facts", + ), ) id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) @@ -707,6 +715,8 @@ class CheckerModelUsageRecovery(Base): ) worker_lease_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) worker_lease_generation: Mapped[int] = mapped_column(Integer, nullable=False) + execution_facts: Mapped[dict] = mapped_column(JSONB, nullable=False) + effect_facts: Mapped[dict] = mapped_column(JSONB, nullable=False) authorization_evidence_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), nullable=False ) @@ -721,7 +731,7 @@ class CheckerModelInvocationGrant(Base): __tablename__ = "checker_model_invocation_grants" __table_args__ = ( CheckConstraint( - "(get_byte(uuid_send(id),6)>>4)=7 and (get_byte(uuid_send(id),8)&192)=128", + _UUID7, name="id_uuid7", ), CheckConstraint( @@ -738,8 +748,8 @@ class CheckerModelInvocationGrant(Base): checker_run_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("checker_runs.id", ondelete="RESTRICT"), nullable=False ) - registry_entry_id: Mapped[str] = mapped_column( - Uuid(as_uuid=False), + registry_entry_id: Mapped[UUID] = mapped_column( + Uuid(), ForeignKey("external_checker_registry_entries.id", ondelete="RESTRICT"), nullable=False, ) diff --git a/backend/app/modules/model_usage/output_custody.py b/backend/app/modules/model_usage/output_custody.py index 890a5f78e..4de841b91 100644 --- a/backend/app/modules/model_usage/output_custody.py +++ b/backend/app/modules/model_usage/output_custody.py @@ -29,9 +29,7 @@ def __init__( self._owner = owner self._session = session - async def resolve( - self, selector: SetupModelOutputSelector - ) -> SetupModelOutputReservation: + async def resolve(self, selector: SetupModelOutputSelector) -> SetupModelOutputReservation: """Resolve one exact setup role without MODEL-to-PROJECTS lock backedges.""" require_model_call_transaction(self._session) selector = SetupModelOutputSelector.model_validate(selector) @@ -63,7 +61,7 @@ async def resolve( or effect.setup_slot_id != slot.id or profile.project_id != str(key.project_id) or ( - slot.compilation_attempt_id, + str(slot.compilation_attempt_id), slot.project_id, slot.guide_id, slot.source_snapshot_id, diff --git a/backend/app/modules/model_usage/repository.py b/backend/app/modules/model_usage/repository.py index 77d52cf60..8551734c6 100644 --- a/backend/app/modules/model_usage/repository.py +++ b/backend/app/modules/model_usage/repository.py @@ -253,7 +253,7 @@ async def envelope_totals(self, envelope_id: str) -> ModelUsageEnvelopeTotals: @staticmethod def _setup_envelope_predicates(key: SetupModelUsageSlotKey): return ( - SetupModelUsageEnvelope.compilation_attempt_id == str(key.compilation_attempt_id), + SetupModelUsageEnvelope.compilation_attempt_id == key.compilation_attempt_id, SetupModelUsageEnvelope.project_id == str(key.project_id), SetupModelUsageEnvelope.guide_id == str(key.guide_id), SetupModelUsageEnvelope.source_snapshot_id == str(key.source_snapshot_id), @@ -278,7 +278,7 @@ async def setup_envelope(self, key: SetupModelUsageSlotKey, *, lock: bool = Fals @staticmethod def _setup_slot_predicates(key: SetupModelUsageSlotKey): return ( - SetupModelUsageSlot.compilation_attempt_id == str(key.compilation_attempt_id), + SetupModelUsageSlot.compilation_attempt_id == key.compilation_attempt_id, SetupModelUsageSlot.runtime_configuration_hash == key.runtime_configuration_hash, SetupModelUsageSlot.profile_id == str(key.profile_id), SetupModelUsageSlot.profile_version == key.profile_version, diff --git a/backend/app/modules/model_usage/service.py b/backend/app/modules/model_usage/service.py index ff969d0dc..42d342d35 100644 --- a/backend/app/modules/model_usage/service.py +++ b/backend/app/modules/model_usage/service.py @@ -841,6 +841,8 @@ async def attach_recovery( checker_run_id=str(execution.checker_run_id), worker_lease_id=str(execution.worker_lease_id), worker_lease_generation=execution.worker_lease_generation, + execution_facts=execution.model_dump(mode="json"), + effect_facts=effect.model_dump(mode="json"), authorization_evidence_id=str(authority.evidence_id), ) ) @@ -906,6 +908,8 @@ async def attach_completed( checker_run_id=str(execution.checker_run_id), worker_lease_id=str(execution.worker_lease_id), worker_lease_generation=execution.worker_lease_generation, + execution_facts=execution.model_dump(mode="json"), + effect_facts=effect.model_dump(mode="json"), authorization_evidence_id=str(authority.evidence_id), ) ) diff --git a/backend/app/modules/model_usage/setup.py b/backend/app/modules/model_usage/setup.py index c20d09bde..8d7b68bd3 100644 --- a/backend/app/modules/model_usage/setup.py +++ b/backend/app/modules/model_usage/setup.py @@ -76,7 +76,7 @@ def _prepared( def _slot_key(slot: SetupModelUsageSlot) -> SetupModelUsageSlotKey: return SetupModelUsageSlotKey( - compilation_attempt_id=UUID(slot.compilation_attempt_id), + compilation_attempt_id=UUID(str(slot.compilation_attempt_id)), project_id=UUID(slot.project_id), guide_id=UUID(slot.guide_id), source_snapshot_id=UUID(slot.source_snapshot_id), @@ -139,18 +139,15 @@ async def _profile( if row is None: raise ModelCallUnavailable("model_profile_unavailable") profile = stored_model_profile(row) - if ( - profile.spec.project_id != key.project_id - or ( - owner_profile is not None - and ( - profile.selector != owner_profile.selector - or (profile.spec.provider, profile.spec.api, profile.spec.model_snapshot) - != ( - owner_profile.provider, - owner_profile.api, - owner_profile.model_snapshot, - ) + if profile.spec.project_id != key.project_id or ( + owner_profile is not None + and ( + profile.selector != owner_profile.selector + or (profile.spec.provider, profile.spec.api, profile.spec.model_snapshot) + != ( + owner_profile.provider, + owner_profile.api, + owner_profile.model_snapshot, ) ) ): @@ -175,16 +172,19 @@ async def prepare( envelope = await self._repo.setup_envelope(key, lock=True) if envelope is None: values = key.model_dump(mode="json", exclude={"phase", "call_index"}) + values["compilation_attempt_id"] = key.compilation_attempt_id envelope = SetupModelUsageEnvelope(id=str(new_record_id()), **values) await self._repo.add(envelope) await self._repo.lock_setup_slot_scope(key) slot = await self._repo.setup_slot(key, lock=True) if slot is None: + slot_values = key.model_dump(mode="json") + slot_values["compilation_attempt_id"] = key.compilation_attempt_id slot = SetupModelUsageSlot( id=str(new_record_id()), envelope_id=envelope.id, request_sha256=request_sha256, - **key.model_dump(mode="json"), + **slot_values, ) await self._repo.add(slot) elif slot.request_sha256 != request_sha256 or slot.envelope_id != envelope.id: @@ -462,8 +462,7 @@ async def complete( if row.state in {"completed", "known_failed"}: if ( row.state != target_state - or - row.provider_response_id != provider_response_id + or row.provider_response_id != provider_response_id or _evidence(row, "response") != response_evidence or _usage(row) != usage or row.settled_nanodollars != cost @@ -478,6 +477,9 @@ async def complete( self._set_usage(row, usage) row.provider_response_id = provider_response_id row.settled_nanodollars = cost + if target_state == "known_failed": + row.settlement_authorization_evidence_id = str(authority.evidence_id) + row.settled_at = await self._repo.now() row.terminal_authorization_evidence_id = str(authority.evidence_id) row.terminal_at = await self._repo.now() row.state = target_state diff --git a/backend/scripts/identifier_generation_classifications.json b/backend/scripts/identifier_generation_classifications.json index 6c8de04a6..101b343e3 100644 --- a/backend/scripts/identifier_generation_classifications.json +++ b/backend/scripts/identifier_generation_classifications.json @@ -18,10 +18,12 @@ }, "sites": { "app/adapters/artifacts/__init__.py|guide_document_access_runtime.authority|Assign:ebf142d9fade54e0|0": "uuid4:call:transport_token", + "app/adapters/artifacts/__init__.py|checker_output_storage.authority|Assign:ebf142d9fade54e0|0": "uuid4:call:transport_token", "app/adapters/artifacts/internal_workers.py|run_artifact_internal_operation|Assign:ebf142d9fade54e0|0": "uuid4:call:transport_token", "app/adapters/artifacts/internal_workers.py|scan_artifact_pending_work|Assign:ebf142d9fade54e0|0": "uuid4:call:transport_token", "app/adapters/artifacts/local.py|LocalStorageAdapter._create_temporary|Assign:77673e2972bd2a92|0": "uuid4:call:ephemeral_storage_name", "app/adapters/auth/__init__.py|guide_compilation_request_authority|AsyncWith:34f3111a2ab9b1eb|0": "uuid5:call:deterministic_selector", + "app/adapters/auth/__init__.py|setup_model_service_authorization|Assign:45b5028da712058a|0": "uuid5:call:deterministic_selector", "app/adapters/auth/__init__.py|post_policy_service_authority|Assign:e417699a1652ebf1|0": "uuid5:call:deterministic_selector", "app/core/api_controls.py|request_ids|Assign:338317bb71eae860|0": "uuid4:call:transport_token", "app/core/api_controls.py|_resolve_request_ids|Assign:055343e0234192a3|0": "uuid4:call:transport_token", @@ -38,6 +40,8 @@ "app/modules/authorization/api/project_guide_projections.py|_uuid|Return:d56c92d39a6e8a97|0": "uuid5:call:deterministic_selector", "app/modules/authorization/api/project_setup_finalization.py|setup_finalization_preparation_identity|Return:203f69a3a3d4f34c|0": "uuid5:call:deterministic_selector", "app/modules/authorization/api/project_setup_finalization.py|setup_finalization_preparation_identity|Return:a4302f57ad6f184f|0": "uuid5:call:deterministic_selector", + "app/modules/authorization/model_usage_authorization.py|CheckerModelAuthorization.authorize_setup_prepare|Return:2044afb99ebde376|0": "uuid5:call:deterministic_selector", + "app/adapters/checkers/model_proxy.py|CheckerModelProxy._handle|Assign:b0d39ceef89a3db0|0": "uuid4:call:transport_token", "app/modules/outbox/delivery.py|OutboxDelivery.claim|AsyncWith:5200885f8fab9061|0": "uuid4:call:transport_token", "app/modules/outbox/delivery.py|OutboxDelivery.claim|AsyncWith:3eebeb550e8493c8|0": "uuid4:call:transport_token", "app/modules/outbox/delivery.py|OutboxDelivery._begin_invocation|AsyncWith:0ddbadb4f6489377|0": "uuid4:call:transport_token", diff --git a/backend/scripts/identifier_inventory.py b/backend/scripts/identifier_inventory.py index fbf8aeeb7..862001922 100644 --- a/backend/scripts/identifier_inventory.py +++ b/backend/scripts/identifier_inventory.py @@ -22,8 +22,14 @@ SEMANTIC_KEYS = { "submission_dispatches": ("submission-owned original dispatch receipt", ("submission_id",)), "submission_binding_receipts": ("admission-owned binding authority receipt", ("admission_id",)), - "review_packet_guide_items": ("packet-to-declared-source membership", ("packet_id", "source_item_id")), - "actor_profile_migration_state": ("seeded schema-state singleton, not a record sequence", ("id",)), + "review_packet_guide_items": ( + "packet-to-declared-source membership", + ("packet_id", "source_item_id"), + ), + "actor_profile_migration_state": ( + "seeded schema-state singleton, not a record sequence", + ("id",), + ), "api_rate_control_counters": ("rate-limit scope and digest", ("control_scope", "key_digest")), "artifact_admission_scopes": ("artifact quota scope", ("scope_type", "scope_id")), "artifact_put_attempt_charges": ("attempt-to-charge association", ("attempt_id", "charge_id")), @@ -33,6 +39,14 @@ "guide_source_extraction_retry_budgets": ("binding-owned shared primary key", ("binding_id",)), "iso_4217_currency_codes": ("ISO currency code", ("code",)), "legacy_actor_identities": ("external legacy actor key", ("actor_id",)), + "model_usage_budget_accounts": ( + "project-owned lifetime budget account", + ("project_id",), + ), + "checker_model_invocation_grant_calls": ( + "grant call-index binding", + ("grant_id", "call_index"), + ), "outbox_delivery_attempts": ("event delivery generation", ("event_id", "claim_generation")), "project_compensation_units": ( "project instrument and unit code", @@ -204,9 +218,12 @@ def parse_orm_models(app_root: Path, report_root: Path | None = None) -> dict[st for owner in [tables.get(target.rpartition(".")[0])] if column["storage_type"] == "inferred" and owner for target_column in [_columns(owner).get(target.rpartition(".")[2])] - if target_column and target_column["storage_kind"] != "other" - and (target_column["storage_type"] != "inferred" - or target_column.get("storage_inferred_from_foreign_key")) + if target_column + and target_column["storage_kind"] != "other" + and ( + target_column["storage_type"] != "inferred" + or target_column.get("storage_inferred_from_foreign_key") + ) } if len(kinds) == 1 and column["storage_kind"] != next(iter(kinds)): column["storage_kind"] = next(iter(kinds)) @@ -303,19 +320,26 @@ def parse_schema(backend_root: Path) -> dict[str, dict[str, Any]]: and ast.unparse(statement.value.func) == "op.drop_table" } downgrade_nodes = { - node for function in tree.body + node + for function in tree.body if isinstance(function, ast.FunctionDef) and function.name == "downgrade" for node in ast.walk(function) } - for item in sorted(ast.walk(tree), key=lambda node: ( - getattr(node, "lineno", 0), getattr(node, "col_offset", 0), - )): + for item in sorted( + ast.walk(tree), + key=lambda node: ( + getattr(node, "lineno", 0), + getattr(node, "col_offset", 0), + ), + ): if item in downgrade_nodes: continue if item in drops and item.args and (table_name := _string(item.args[0])): - if all(keyword.arg is not None and ( - keyword.arg != "schema" or _string(keyword.value) == "public" - ) for keyword in item.keywords): + if all( + keyword.arg is not None + and (keyword.arg != "schema" or _string(keyword.value) == "public") + for keyword in item.keywords + ): tables.pop(table_name, None) continue if ( @@ -331,14 +355,16 @@ def parse_schema(backend_root: Path) -> dict[str, dict[str, Any]]: or not (table_name := _string(item.args[0])) ): continue - primary_key = list(dict.fromkeys( - name - for argument in item.args[1:] - if isinstance(argument, ast.Call) - and _name(argument.func) == "PrimaryKeyConstraint" - for value in argument.args - if (name := _string(value)) - )) + primary_key = list( + dict.fromkeys( + name + for argument in item.args[1:] + if isinstance(argument, ast.Call) + and _name(argument.func) == "PrimaryKeyConstraint" + for value in argument.args + if (name := _string(value)) + ) + ) columns = [] for argument in item.args[1:]: if ( @@ -526,9 +552,7 @@ def scan_generation_sites( continue tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) parents = { - child: parent - for parent in ast.walk(tree) - for child in ast.iter_child_nodes(parent) + child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent) } direct, modules = _uuid_bindings(tree) occurrences = _uuid_occurrences(tree, parents, direct, modules) @@ -565,7 +589,9 @@ def _key(table: dict[str, Any], orm_present: bool) -> dict[str, str]: ) if not orm_present: return {"classification": "unresolved", "reason": "schema table has no ORM owner"} - if table["name"] == "task_post_submit_routing_requests" and table["primary_key"] == ["route_operation_id"]: + if table["name"] == "task_post_submit_routing_requests" and table["primary_key"] == [ + "route_operation_id" + ]: return { "classification": "generated_surrogate", "reason": "TASK-owned generated routing operation record", @@ -613,9 +639,7 @@ def _string_uuid_references(tables: list[dict[str, Any]]) -> list[dict[str, str] "candidate": "convert relationship to native UUID", } ) - return sorted( - references, key=lambda item: (item["table"], item["column"], item["target"]) - ) + return sorted(references, key=lambda item: (item["table"], item["column"], item["target"])) def _generation_inventory(backend_root: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: @@ -680,15 +704,21 @@ def build_inventory(backend_root: Path) -> dict[str, Any]: if key["classification"] == "unresolved": unresolved.append({"kind": "table_key", "table": name, "reason": key["reason"]}) if owner and not ddl: - unresolved.append({ - "kind": "missing_schema_table", "table": name, - "reason": "ORM table is absent from the schema inventory", - }) + unresolved.append( + { + "kind": "missing_schema_table", + "table": name, + "reason": "ORM table is absent from the schema inventory", + } + ) if owner and ddl and owner["primary_key"] != ddl["primary_key"]: - unresolved.append({ - "kind": "primary_key_shape_mismatch", "table": name, - "reason": "ORM and schema primary keys differ", - }) + unresolved.append( + { + "kind": "primary_key_shape_mismatch", + "table": name, + "reason": "ORM and schema primary keys differ", + } + ) if owner and ddl and owner["primary_key"] == ddl["primary_key"]: for column_name in owner["primary_key"]: left, right = _columns(owner).get(column_name), _columns(ddl).get(column_name) diff --git a/backend/tests/checker_output_admission_helpers.py b/backend/tests/checker_output_admission_helpers.py index 7f11c3a2a..45a8fc5e2 100644 --- a/backend/tests/checker_output_admission_helpers.py +++ b/backend/tests/checker_output_admission_helpers.py @@ -51,8 +51,11 @@ _ISOLATED_DATABASE_RE = re.compile(r"workstream_test_([a-f0-9]{12})") _ISOLATED_ROLE_RE = re.compile(r"workstream_role_([a-f0-9]{12})") _CONTROLLED_MODEL_GUARDS = { - "model_usage_profiles": "model_usage_profiles_guard", - "model_usage_effects": "model_usage_effects_guard", + "model_usage_profiles": ("model_usage_profiles_guard",), + "model_usage_effects": ( + "model_usage_effects_guard", + "checker_model_call_attempt_reconcile", + ), } @@ -61,8 +64,8 @@ async def _suspend_controlled_model_guard( session: AsyncSession, *, table: str ) -> AsyncIterator[None]: """Suspend one named guard only in the isolated database for this ART fixture.""" - trigger = _CONTROLLED_MODEL_GUARDS.get(table) - if trigger is None: + triggers = _CONTROLLED_MODEL_GUARDS.get(table) + if triggers is None: raise RuntimeError("unsupported model usage guard suspension") database_name, database_role = ( await session.execute(text("select current_database(), current_user")) @@ -76,17 +79,20 @@ async def _suspend_controlled_model_guard( ): raise RuntimeError("model usage guard suspension requires an isolated test database") try: - await session.execute(text(f"alter table {table} disable trigger {trigger}")) + for trigger in triggers: + await session.execute(text(f"alter table {table} disable trigger {trigger}")) yield # PostgreSQL will not ALTER a table with deferred FK events pending. This # fixture owns its dedicated arrangement session, so finish the controlled # seed before restoring the guard in a new transaction. await session.commit() - await session.execute(text(f"alter table {table} enable trigger {trigger}")) + for trigger in triggers: + await session.execute(text(f"alter table {table} enable trigger {trigger}")) await session.commit() except BaseException: await session.rollback() - await session.execute(text(f"alter table {table} enable trigger {trigger}")) + for trigger in triggers: + await session.execute(text(f"alter table {table} enable trigger {trigger}")) await session.commit() raise diff --git a/backend/tests/checkers_model_calls/model_usage_pg_support.py b/backend/tests/checkers_model_calls/model_usage_pg_support.py new file mode 100644 index 000000000..31340e585 --- /dev/null +++ b/backend/tests/checkers_model_calls/model_usage_pg_support.py @@ -0,0 +1,118 @@ +"""Shared isolated PostgreSQL substrate for MODEL-USAGE owner tests.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import runpy +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id + + +def _sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +@dataclass(frozen=True) +class DatabaseFacts: + """Stable owner identities and engine for one isolated MODEL schema.""" + + engine: AsyncEngine + project_id: UUID + task_id: UUID + assignment_id: UUID + submission_id: UUID + registry_entry_id: UUID + registry_entry_digest: str + + +@pytest.fixture +async def model_database(postgres_database_url: str): + """Install 0061 against the minimal real owner tables used by MODEL tests.""" + schema = f"model_calls_{uuid4().hex}" + engine = create_async_engine( + postgres_database_url, + connect_args={"server_settings": {"search_path": schema}}, + ) + project_id = new_record_id() + task_id = new_record_id() + assignment_id = new_record_id() + submission_id = new_record_id() + registry_entry_id = new_record_id() + registry_entry_digest = _sha("registry") + migration = runpy.run_path( + str(Path(__file__).resolve().parents[2] / "alembic/versions/0061_model_usage.py") + ) + + def isolated(statement: str) -> str: + return statement.replace("public.", f'"{schema}".').replace( + "pg_catalog,public,pg_temp", f'pg_catalog,"{schema}",pg_temp' + ) + + async with engine.begin() as connection: + await connection.execute(text(f'create schema "{schema}"')) + for statement in ( + "create table projects(id uuid primary key)", + "create table workstream_tasks(id uuid primary key, project_id uuid not null, unique(id,project_id))", + "create table task_assignments(id uuid primary key, task_id uuid not null, project_id uuid not null)", + "create table submissions(id uuid primary key, task_id uuid not null, version integer not null, unique(id,task_id,version))", + "create table external_checker_registry_entries(id uuid primary key,entry_digest text not null,phase text not null)", + "create table checker_runs(id uuid primary key,task_id uuid,submission_id uuid,submission_version integer,project_id uuid,evaluation_request_id uuid,phase text,evaluation_generation integer,request_json text,worker_lease_id uuid,worker_lease_generation integer,worker_lease_expires_at timestamptz,status text)", + "create table actor_profiles(id uuid primary key,actor_kind text default 'human',status text default 'active',service_identity text)", + "create table audit_events(id uuid primary key, event_domain text, event_type text, actor_ref_kind text, actor_id text, action_id text, permission_id text, denial_code text, after_facts json)", + "create table artifact_bindings(id uuid primary key)", + "create table artifact_contents(id uuid primary key)", + "create table artifact_put_attempts(id uuid primary key)", + "create table artifact_verification_receipts(id uuid primary key)", + "create table project_guide_compilation_attempts(id uuid primary key,project_id uuid not null,guide_id uuid not null,source_snapshot_id uuid not null,setup_run_id uuid not null,setup_generation bigint not null,runtime_configuration jsonb,runtime_configuration_hash text,status text)", + ): + await connection.execute(text(statement)) + for statement in migration["_TABLES"]: + await connection.execute(text(isolated(statement))) + for statement in migration["_FUNCTIONS"]: + await connection.execute(text(isolated(statement))) + for statement in migration["_TRIGGERS"]: + await connection.execute(text(isolated(statement))) + await connection.execute(text("insert into projects(id) values (:id)"), {"id": project_id}) + await connection.execute( + text("insert into workstream_tasks(id,project_id) values (:id,:project)"), + {"id": task_id, "project": project_id}, + ) + await connection.execute( + text("insert into task_assignments(id,task_id,project_id) values (:id,:task,:project)"), + {"id": assignment_id, "task": task_id, "project": project_id}, + ) + await connection.execute( + text("insert into submissions(id,task_id,version) values (:id,:task,1)"), + {"id": submission_id, "task": task_id}, + ) + await connection.execute( + text( + "insert into external_checker_registry_entries(id,entry_digest,phase) " + "values (:id,:digest,'post_submit')" + ), + {"id": registry_entry_id, "digest": registry_entry_digest}, + ) + try: + yield DatabaseFacts( + engine, + project_id, + task_id, + assignment_id, + submission_id, + registry_entry_id, + registry_entry_digest, + ) + finally: + try: + async with engine.begin() as connection: + await connection.execute(text("set local search_path = pg_catalog")) + await connection.execute(text(f'drop schema if exists "{schema}" cascade')) + finally: + await engine.dispose() diff --git a/backend/tests/checkers_model_calls/test_composed_execution.py b/backend/tests/checkers_model_calls/test_composed_execution.py index 8e3839fe8..c7ddef26c 100644 --- a/backend/tests/checkers_model_calls/test_composed_execution.py +++ b/backend/tests/checkers_model_calls/test_composed_execution.py @@ -27,7 +27,10 @@ CheckerModelUsage, ) from app.interfaces.checker_model_proxy import ModelProxyRequest -from app.interfaces.external_services import ExternalServiceAdapterIdentity, ExternalServiceUnavailableError +from app.interfaces.external_services import ( + ExternalServiceAdapterIdentity, + ExternalServiceUnavailableError, +) from app.interfaces.project_agents import CapabilityParameter, CheckSpecification from app.modules.actors.api import ServiceIdentity from app.modules.artifacts.preparation import HARD_MAXIMUM_ARTIFACT_BYTES @@ -58,13 +61,15 @@ def model_review_specification(): execution_mode="background", maximum_duration_seconds=120, stage_rationale="Content review runs after immutable submission creation.", - runtime_dependencies=[{ - "dependency_id": "model_runtime", - "kind": "model_service", - "provisioning": "model_proxy", - "purpose": "Evaluate submitted evidence through the budgeted model service.", - "unavailable_code": "implementation_unavailable", - }], + runtime_dependencies=[ + { + "dependency_id": "model_runtime", + "kind": "model_service", + "provisioning": "model_proxy", + "purpose": "Evaluate submitted evidence through the budgeted model service.", + "unavailable_code": "implementation_unavailable", + } + ], infrastructure_failures=[ *specification["infrastructure_failures"], { @@ -96,7 +101,7 @@ def model_registry_spec(): schema_version="v1", document=document, schema_sha256=external_checker_json_hash(document), - ) + ), } ) @@ -152,6 +157,8 @@ async def publish(_factory, project_id, registry): json={ "project_id": str(project_id), "configuration_version": 1, + "authorized_total_nanodollars": 10_000_000_000, + "externally_reconciled_prior_spend_nanodollars": 0, "lifetime_limit_nanodollars": 10_000_000_000, }, ) @@ -202,7 +209,9 @@ async def create(self, request): observation=CheckerModelObservation( reference, profiles[0]["spec"]["model_snapshot"], - CheckerModelProviderStatus.FAILED if failed else CheckerModelProviderStatus.COMPLETED, + CheckerModelProviderStatus.FAILED + if failed + else CheckerModelProviderStatus.COMPLETED, None if failed else '{"verdict":"passed"}', CheckerModelUsage(20, 5, 10, 2, 30), ), @@ -250,14 +259,18 @@ async def execute(self, request, grant, *, model_grant=None): ) if concurrent_callback: replies = await asyncio.gather(proxy.handle(callback), proxy.handle(callback)) - assert sorted(reply.outcome for reply in replies) == ["completed", "unavailable"] + assert sorted(reply.outcome for reply in replies) == [ + "completed", + "unavailable", + ] reply = next(reply for reply in replies if reply.outcome == "completed") else: reply = await proxy.handle(callback) if terminal_mode is not None and self.invocations == 1: assert reply.outcome == "unavailable", reply.outcome assert reply.failure_code == ( - "model_provider_no_effect" if terminal_mode == "no_effect" + "model_provider_no_effect" + if terminal_mode == "no_effect" else "model_provider_failed" ) raise ExternalServiceUnavailableError(self.identity) @@ -298,11 +311,15 @@ async def execute(self, request, grant, *, model_grant=None): ) recovery_headers = admin_access.target.headers child = await admin_access.signed.client.post( - recovery_path, headers=recovery_headers, json=candidate, + recovery_path, + headers=recovery_headers, + json=candidate, ) assert child.status_code == 201, child.text replay = await admin_access.signed.client.post( - recovery_path, headers=recovery_headers, json=candidate, + recovery_path, + headers=recovery_headers, + json=candidate, ) assert replay.status_code == 201, replay.text assert replay.json()["attempt_id"] == child.json()["attempt_id"] @@ -311,7 +328,9 @@ async def execute(self, request, grant, *, model_grant=None): f"/api/v1/operations/projects/{h.request.project_id}/checker-runs/" f"{h.created.evaluation_attempt_id}/retry", headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, - json={"reason": "Recover lost image response without repeating the paid model call"}, + json={ + "reason": "Recover lost image response without repeating the paid model call" + }, ) assert retry.status_code == 201, retry.text async with h.factory() as session, session.begin(): @@ -329,9 +348,8 @@ async def execute(self, request, grant, *, model_grant=None): assert await executor.evaluate_post_submission(current_request) == result assert len(creates) == (2 if terminal_mode else 1) async with h.factory() as session: - assert ( - await session.scalar(select(func.count()).select_from(ModelUsageEffect)) - == (2 if terminal_mode else 1) + assert await session.scalar(select(func.count()).select_from(ModelUsageEffect)) == ( + 2 if terminal_mode else 1 ) attempt = await session.scalar( select(ModelUsageEffect).where(ModelUsageEffect.state == "completed") diff --git a/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py index 96c10f129..90c7a1818 100644 --- a/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py @@ -4,20 +4,21 @@ from datetime import UTC, datetime, timedelta import asyncio import json -from pathlib import Path -import runpy from uuid import UUID, uuid4 import pytest from sqlalchemy import text from sqlalchemy.exc import DBAPIError -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id from app.modules.authorization.domain.model_usage import ( BUDGET_CONFIGURE, PROFILE_PROVISION, + RECOVER, + ModelCallRecoverAuthorityFacts, + checker_model_call_resource, project_model_admin_resource, ) from app.modules.authorization.domain.resource_digest import authorization_resource_digest @@ -47,101 +48,18 @@ from app.modules.model_usage.profiles import ModelProfileReader from app.modules.model_usage.service import ModelCallService from app.modules.model_usage.setup import SetupModelUsageService +from tests.checkers_model_calls.model_usage_pg_support import ( + DatabaseFacts, + model_database as _model_database, +) + +model_database = _model_database def sha(label: str) -> str: return canonical_json_hash({"label": label}) -@dataclass(frozen=True) -class DatabaseFacts: - engine: AsyncEngine - project_id: UUID - task_id: UUID - assignment_id: UUID - submission_id: UUID - registry_entry_id: UUID - registry_entry_digest: str - - -@pytest.fixture -async def model_database(postgres_database_url: str): - schema = f"model_calls_{uuid4().hex}" - engine = create_async_engine( - postgres_database_url, - connect_args={"server_settings": {"search_path": schema}}, - ) - project_id = new_record_id() - task_id = new_record_id() - assignment_id = new_record_id() - submission_id = new_record_id() - registry_entry_id = new_record_id() - registry_entry_digest = sha("registry") - migration = runpy.run_path(str(Path(__file__).resolve().parents[2] / "alembic/versions/0061_model_usage.py")) - - def isolated(statement: str) -> str: - return statement.replace("public.", f'"{schema}".').replace("pg_catalog,public,pg_temp", f'pg_catalog,"{schema}",pg_temp') - - async with engine.begin() as connection: - await connection.execute(text(f'create schema "{schema}"')) - for statement in ( - "create table projects(id uuid primary key)", - "create table workstream_tasks(id uuid primary key, project_id uuid not null, unique(id,project_id))", - "create table task_assignments(id uuid primary key, task_id uuid not null, project_id uuid not null)", - "create table submissions(id uuid primary key, task_id uuid not null, version integer not null, unique(id,task_id,version))", - "create table external_checker_registry_entries(id uuid primary key,entry_digest text not null,phase text not null)", - "create table checker_runs(id uuid primary key)", - "create table actor_profiles(id uuid primary key)", - "create table audit_events(id uuid primary key, event_domain text, event_type text, actor_ref_kind text, actor_id text, action_id text, permission_id text, denial_code text, after_facts json)", - "create table artifact_bindings(id uuid primary key)", - "create table artifact_contents(id uuid primary key)", - "create table artifact_put_attempts(id uuid primary key)", - "create table artifact_verification_receipts(id uuid primary key)", - "create table project_guide_compilation_attempts(id uuid primary key,project_id uuid not null,guide_id uuid not null,source_snapshot_id uuid not null,setup_run_id uuid not null,setup_generation bigint not null,runtime_configuration jsonb,runtime_configuration_hash text,status text)", - ): - await connection.execute(text(statement)) - for statement in migration["_TABLES"]: - await connection.execute(text(isolated(statement))) - for statement in migration["_FUNCTIONS"]: - await connection.execute(text(isolated(statement))) - for statement in migration["_TRIGGERS"]: - await connection.execute(text(isolated(statement))) - await connection.execute(text("insert into projects(id) values (:id)"), {"id": project_id}) - await connection.execute( - text("insert into workstream_tasks(id,project_id) values (:id,:project)"), - {"id": task_id, "project": project_id}, - ) - await connection.execute( - text("insert into task_assignments(id,task_id,project_id) values (:id,:task,:project)"), - {"id": assignment_id, "task": task_id, "project": project_id}, - ) - await connection.execute( - text("insert into submissions(id,task_id,version) values (:id,:task,1)"), - {"id": submission_id, "task": task_id}, - ) - await connection.execute( - text("insert into external_checker_registry_entries(id,entry_digest,phase) values (:id,:digest,'post_submit')"), - {"id": registry_entry_id, "digest": registry_entry_digest}, - ) - try: - yield DatabaseFacts( - engine, - project_id, - task_id, - assignment_id, - submission_id, - registry_entry_id, - registry_entry_digest, - ) - finally: - try: - async with engine.begin() as connection: - await connection.execute(text("set local search_path = pg_catalog")) - await connection.execute(text(f'drop schema if exists "{schema}" cascade')) - finally: - await engine.dispose() - - @dataclass class BoundaryPorts: decisions: dict[str, UUID] = field(default_factory=dict) @@ -155,7 +73,11 @@ class BoundaryPorts: def _decision(self, action: str, facts: object) -> ModelActionEvidence: self.events.append(action) digest = model_action_digest(action, facts) - evidence_id = new_record_id() if self.fresh_authority_evidence else self.decisions.setdefault(digest, new_record_id()) + evidence_id = ( + new_record_id() + if self.fresh_authority_evidence + else self.decisions.setdefault(digest, new_record_id()) + ) return ModelActionEvidence(evidence_id=evidence_id, facts_digest=digest) async def authorize_profile(self, facts): @@ -218,7 +140,11 @@ async def authorize_complete(self, effect, response_evidence, terminal_digest): "complete", { "effect": effect.model_dump(mode="json"), - "response_evidence": (response_evidence.model_dump(mode="json") if response_evidence is not None else None), + "response_evidence": ( + response_evidence.model_dump(mode="json") + if response_evidence is not None + else None + ), "terminal_digest": terminal_digest, }, ) @@ -253,7 +179,11 @@ async def authorize_setup_complete(self, effect, response_evidence, terminal_dig "setup_complete", { "effect": effect.model_dump(mode="json"), - "response_evidence": (response_evidence.model_dump(mode="json") if response_evidence is not None else None), + "response_evidence": ( + response_evidence.model_dump(mode="json") + if response_evidence is not None + else None + ), "terminal_digest": terminal_digest, }, ) @@ -286,7 +216,9 @@ def __init__(self, session: AsyncSession, ports: BoundaryPorts): self._session = session self._ports = ports - async def _authority(self, evidence, *, action: str, permission: str, actor_id=None, resource_digest=None): + async def _authority( + self, evidence, *, action: str, permission: str, actor_id=None, resource_digest=None + ): actor = str(actor_id or new_record_id()) if actor_id is not None: await self._session.execute( @@ -294,7 +226,9 @@ async def _authority(self, evidence, *, action: str, permission: str, actor_id=N {"id": actor_id}, ) await self._session.execute( - text("insert into audit_events(id,event_domain,event_type,actor_ref_kind,actor_id,action_id,permission_id,denial_code,after_facts) values (:id,'authority','SensitiveAuthorizationAllowed','actor_profile',:actor,:action,:permission,null,cast(:after as json))"), + text( + "insert into audit_events(id,event_domain,event_type,actor_ref_kind,actor_id,action_id,permission_id,denial_code,after_facts) values (:id,'authority','SensitiveAuthorizationAllowed','actor_profile',:actor,:action,:permission,null,cast(:after as json))" + ), { "id": evidence.evidence_id, "actor": actor, @@ -303,7 +237,11 @@ async def _authority(self, evidence, *, action: str, permission: str, actor_id=N "after": json.dumps( { "allowed": True, - "resource_context_digest": (sha("substituted-authority") if actor_id is not None and self._ports.corrupt_admin_receipt else resource_digest or sha("authority")), + "resource_context_digest": ( + sha("substituted-authority") + if actor_id is not None and self._ports.corrupt_admin_receipt + else resource_digest or sha("authority") + ), } ), }, @@ -317,7 +255,11 @@ async def authorize_profile(self, facts): action="project.model_profile.provision", permission="project.model_profile.manage", actor_id=facts.actor_profile_id, - resource_digest=authorization_resource_digest(project_model_admin_resource(PROFILE_PROVISION, facts, actor_profile_id=facts.actor_profile_id)), + resource_digest=authorization_resource_digest( + project_model_admin_resource( + PROFILE_PROVISION, facts, actor_profile_id=facts.actor_profile_id + ) + ), ) async def authorize_budget(self, facts): @@ -327,12 +269,18 @@ async def authorize_budget(self, facts): action="project.model_budget.configure", permission="project.model_budget.manage", actor_id=facts.actor_profile_id, - resource_digest=authorization_resource_digest(project_model_admin_resource(BUDGET_CONFIGURE, facts, actor_profile_id=facts.actor_profile_id)), + resource_digest=authorization_resource_digest( + project_model_admin_resource( + BUDGET_CONFIGURE, facts, actor_profile_id=facts.actor_profile_id + ) + ), ) async def authorize_prepare(self, execution, slot, request_sha256): evidence = await self._ports.authorize_prepare(execution, slot, request_sha256) - return await self._authority(evidence, action="checker.model_call.prepare", permission="checker.model_call.prepare") + return await self._authority( + evidence, action="checker.model_call.prepare", permission="checker.model_call.prepare" + ) async def authorize_dispatch(self, execution, attempt, request_evidence): evidence = await self._ports.authorize_dispatch(execution, attempt, request_evidence) @@ -344,11 +292,32 @@ async def authorize_dispatch(self, execution, attempt, request_evidence): async def authorize_recover(self, execution, effect): evidence = await self._ports.authorize_recover(execution, effect) - return await self._authority(evidence, action="checker.model_call.recover", permission="checker.model_call.recover") + actor_id = new_record_id() + await self._session.execute( + text( + "insert into actor_profiles(id,actor_kind,status,service_identity) " + "values (:id,'service','active','workstream.checker.post_submit')" + ), + {"id": actor_id}, + ) + facts = ModelCallRecoverAuthorityFacts(execution=execution, effect=effect) + return await self._authority( + evidence, + action="checker.model_call.recover", + permission="checker.model_call.recover", + actor_id=actor_id, + resource_digest=authorization_resource_digest( + checker_model_call_resource(RECOVER, facts) + ), + ) async def authorize_observe(self, effect, provider_response_id, observation_digest): - evidence = await self._ports.authorize_observe(effect, provider_response_id, observation_digest) - return await self._authority(evidence, action="checker.model_call.observe", permission="checker.model_call.observe") + evidence = await self._ports.authorize_observe( + effect, provider_response_id, observation_digest + ) + return await self._authority( + evidence, action="checker.model_call.observe", permission="checker.model_call.observe" + ) async def authorize_child(self, project_id, parent_attempt_id, terminal_digest): evidence = await self._ports.authorize_child(project_id, parent_attempt_id, terminal_digest) @@ -383,7 +352,9 @@ async def authorize_setup_dispatch(self, prepared, request_evidence): ) async def authorize_setup_observe(self, effect, provider_response_id, observation_digest): - evidence = await self._ports.authorize_setup_observe(effect, provider_response_id, observation_digest) + evidence = await self._ports.authorize_setup_observe( + effect, provider_response_id, observation_digest + ) return await self._authority( evidence, action="project.guide_compilation.model.observe", @@ -391,7 +362,9 @@ async def authorize_setup_observe(self, effect, provider_response_id, observatio ) async def authorize_setup_complete(self, effect, response_evidence, terminal_digest): - evidence = await self._ports.authorize_setup_complete(effect, response_evidence, terminal_digest) + evidence = await self._ports.authorize_setup_complete( + effect, response_evidence, terminal_digest + ) return await self._authority( evidence, action="project.guide_compilation.model.complete", @@ -408,7 +381,9 @@ async def require_setup_response(self, effect_id, evidence): async def require_setup_current(self, slot): attempt_id = await self._session.scalar( - text("select id from project_guide_compilation_attempts where id=:id and project_id=:project and guide_id=:guide and source_snapshot_id=:snapshot and setup_run_id=:run and setup_generation=:generation and runtime_configuration_hash=:runtime and status='compilation_reserved' for update"), + text( + "select id from project_guide_compilation_attempts where id=:id and project_id=:project and guide_id=:guide and source_snapshot_id=:snapshot and setup_run_id=:run and setup_generation=:generation and runtime_configuration_hash=:runtime and status='compilation_reserved' for update" + ), { "id": slot.compilation_attempt_id, "project": slot.project_id, @@ -500,7 +475,9 @@ async def setup_slot_key( runtime_hash = canonical_json_hash(runtime_configuration) async with facts.engine.begin() as connection: await connection.execute( - text("insert into project_guide_compilation_attempts(id,project_id,guide_id,source_snapshot_id,setup_run_id,setup_generation,runtime_configuration,runtime_configuration_hash,status) values(:id,:project,:guide,:snapshot,:run,1,cast(:configuration as jsonb),:runtime,'compilation_reserved')"), + text( + "insert into project_guide_compilation_attempts(id,project_id,guide_id,source_snapshot_id,setup_run_id,setup_generation,runtime_configuration,runtime_configuration_hash,status) values(:id,:project,:guide,:snapshot,:run,1,cast(:configuration as jsonb),:runtime,'compilation_reserved')" + ), { "id": attempt_id, "project": facts.project_id, @@ -594,8 +571,12 @@ async def provision( if replay: replayed_profile = await owner.provision_profile(profile_facts) replayed_budget = await owner.provision_budget(budget_facts) - assert replayed_profile.authorization_evidence_id == (profile.authorization_evidence_id) - assert replayed_budget.authorization_evidence_id == (budget.authorization_evidence_id) + assert replayed_profile.authorization_evidence_id == ( + profile.authorization_evidence_id + ) + assert replayed_budget.authorization_evidence_id == ( + budget.authorization_evidence_id + ) return profile @@ -615,6 +596,32 @@ def execution(facts: DatabaseFacts) -> ModelCallExecutionFacts: ) +async def insert_checker_run(session: AsyncSession, run: ModelCallExecutionFacts) -> None: + """Persist the exact running lease used by a recovery attachment.""" + await session.execute( + text( + "insert into checker_runs(id,task_id,submission_id,submission_version,project_id," + "evaluation_request_id,phase,evaluation_generation,request_json,worker_lease_id," + "worker_lease_generation,worker_lease_expires_at,status) values " + "(:id,:task,:submission,:version,:project,:request,'post_submission',:generation," + ":request_json,:lease,:lease_generation,:expires,'running')" + ), + { + "id": run.checker_run_id, + "task": run.task_id, + "submission": run.submission_id, + "version": run.submission_version, + "project": run.project_id, + "request": run.evaluation_request_id, + "generation": run.evaluation_generation, + "request_json": json.dumps({"assignment_id": str(run.assignment_id)}), + "lease": run.worker_lease_id, + "lease_generation": run.worker_lease_generation, + "expires": run.worker_lease_expires_at, + }, + ) + + def slot(facts: DatabaseFacts, profile, requirement: str) -> ModelPaidSlotKey: return ModelPaidSlotKey( project_id=facts.project_id, @@ -650,7 +657,9 @@ async def wait_until_blocked_by( """Observe an actual PostgreSQL lock wait instead of inferring final totals.""" for _ in range(200): async with engine.connect() as connection: - blockers = await connection.scalar(text("select pg_blocking_pids(:pid)"), {"pid": blocked_pid}) + blockers = await connection.scalar( + text("select pg_blocking_pids(:pid)"), {"pid": blocked_pid} + ) if blocker_pid in blockers: return await asyncio.sleep(0.01) @@ -765,7 +774,9 @@ async def test_terminal_holds_envelope_before_other_slot_and_account( terminal_pid = await terminal.scalar(text("select pg_backend_pid()")) contender_pid = await contender.scalar(text("select pg_backend_pid()")) await holder.execute( - text("select project_id from model_usage_budget_accounts where project_id=:project_id for update"), + text( + "select project_id from model_usage_budget_accounts where project_id=:project_id for update" + ), {"project_id": model_database.project_id}, ) @@ -834,7 +845,9 @@ async def test_uncertain_holds_budget_and_only_proven_no_effect_allows_child( ports = BoundaryPorts() reservation = 100 * 11 + 50 * 29 profile = await provision(model_database, ports, limit=reservation * 2) - run, paid_slot, prepared, dispatched, _ = await prepare_and_reserve(model_database, ports, profile, "model_recovery") + run, paid_slot, prepared, dispatched, _ = await prepare_and_reserve( + model_database, ports, profile, "model_recovery" + ) uncertainty = sha("uncertain-delivery") async with AsyncSession(model_database.engine, expire_on_commit=False) as session: async with session.begin(): @@ -863,7 +876,9 @@ async def test_uncertain_holds_budget_and_only_proven_no_effect_allows_child( assert account.lifetime_reserved_nanodollars == reservation # A distinct call proves the only zero-cost child path: authenticated no effect. - run2, paid_slot2, prepared2, dispatched2, _ = await prepare_and_reserve(model_database, ports, profile, "model_no_effect") + run2, paid_slot2, prepared2, dispatched2, _ = await prepare_and_reserve( + model_database, ports, profile, "model_no_effect" + ) proof = KnownNoEffectProof( proof_id=new_record_id(), proof_digest=sha("provider-no-effect"), @@ -900,7 +915,9 @@ async def test_budget_adjustment_preserves_reserved_and_settles_old_snapshot( ports = BoundaryPorts() reservation = 100 * 11 + 50 * 29 profile = await provision(model_database, ports, limit=reservation) - _run, _paid_slot, _prepared, dispatched, _ = await prepare_and_reserve(model_database, ports, profile, "model_budget_adjustment") + _run, _paid_slot, _prepared, dispatched, _ = await prepare_and_reserve( + model_database, ports, profile, "model_budget_adjustment" + ) adjusted_spec = ProjectModelBudgetConfigurationSpec( project_id=model_database.project_id, configuration_version=2, @@ -922,7 +939,9 @@ async def test_budget_adjustment_preserves_reserved_and_settles_old_snapshot( # The explicitly increased ceiling admits another dispatch without resetting # the first reservation or counting ledger usage as external prior spend. - _second = await prepare_and_reserve(model_database, ports, profile, "model_budget_adjustment_second") + _second = await prepare_and_reserve( + model_database, ports, profile, "model_budget_adjustment_second" + ) too_low_spec = ProjectModelBudgetConfigurationSpec( project_id=model_database.project_id, @@ -1014,7 +1033,9 @@ async def test_known_failed_settles_once_and_allows_authorized_child( ports = BoundaryPorts() reservation = 100 * 11 + 50 * 29 profile = await provision(model_database, ports, limit=reservation * 2) - run, paid_slot, prepared, dispatched, _ = await prepare_and_reserve(model_database, ports, profile, "model_known_failure") + run, paid_slot, prepared, dispatched, _ = await prepare_and_reserve( + model_database, ports, profile, "model_known_failure" + ) usage = ModelCallUsage( input_tokens=22, cached_input_tokens=2, @@ -1059,13 +1080,17 @@ async def test_database_guards_reject_ledger_mutation_and_counter_substitution( ports = BoundaryPorts() reservation = 100 * 11 + 50 * 29 profile = await provision(model_database, ports, limit=reservation * 2) - _, _, _, dispatched, _ = await prepare_and_reserve(model_database, ports, profile, "model_guarded") + _, _, _, dispatched, _ = await prepare_and_reserve( + model_database, ports, profile, "model_guarded" + ) async with AsyncSession(model_database.engine) as session: with pytest.raises(DBAPIError): async with session.begin(): await session.execute( - text("update checker_model_usage_slots set first_request_sha256=:digest where id=:slot_id"), + text( + "update checker_model_usage_slots set first_request_sha256=:digest where id=:slot_id" + ), {"digest": sha("substituted"), "slot_id": dispatched.slot_id}, ) @@ -1073,7 +1098,9 @@ async def test_database_guards_reject_ledger_mutation_and_counter_substitution( with pytest.raises(DBAPIError): async with session.begin(): await session.execute( - text("update model_usage_budget_accounts set lifetime_reserved_nanodollars=lifetime_reserved_nanodollars-1 where project_id=:project_id"), + text( + "update model_usage_budget_accounts set lifetime_reserved_nanodollars=lifetime_reserved_nanodollars-1 where project_id=:project_id" + ), {"project_id": model_database.project_id}, ) @@ -1088,7 +1115,9 @@ async def test_database_ancestry_prevents_receipt_and_artifact_deletion( ) -> None: ports = BoundaryPorts() profile = await provision(model_database, ports, limit=10_000) - _, _, _, _, request = await prepare_and_reserve(model_database, ports, profile, "model_ancestry") + _, _, _, _, request = await prepare_and_reserve( + model_database, ports, profile, "model_ancestry" + ) for statement, identifier in ( ("delete from actor_profiles where id=:id", profile.actor_profile_id), ("delete from audit_events where id=:id", profile.authorization_evidence_id), @@ -1125,6 +1154,8 @@ async def test_profile_reader_requires_exact_project_and_selector( await reader.read_exact(profile.selector, project_id=new_record_id()) with pytest.raises(ModelCallUnavailable, match="model_profile_unavailable"): await reader.read_exact( - profile.selector.model_copy(update={"profile_digest": sha("substituted-profile")}), + profile.selector.model_copy( + update={"profile_digest": sha("substituted-profile")} + ), project_id=model_database.project_id, ) diff --git a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py index 51a91a8cf..8e5988a56 100644 --- a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py @@ -23,11 +23,13 @@ model_action_digest, ) from app.modules.model_usage.grants import ModelInvocationGrants +from tests.checkers_model_calls.model_usage_pg_support import ( + DatabaseFacts, + model_database as _ledger_database, +) from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( BoundaryPorts, - DatabaseFacts, execution, - model_database as _ledger_database, provision, service, sha, @@ -310,22 +312,23 @@ async def test_grant_round_trip_binds_exact_calls_and_becomes_immutable( request_sha256=second_request, prepared_attempt_id=second_attempt.attempt_id, ) - assert await session.scalar( - text( - "select count(*) from checker_model_invocation_grant_calls " - "where grant_id=:grant" - ), - {"grant": row.grant_id}, - ) == 2 + assert ( + await session.scalar( + text( + "select count(*) from checker_model_invocation_grant_calls " + "where grant_id=:grant" + ), + {"grant": row.grant_id}, + ) + == 2 + ) for mutation in ( "update checker_model_invocation_grants set external_request_digest=" "'sha256:" + "d" * 64 + "'", "delete from checker_model_invocation_grants", "truncate checker_model_invocation_grants", - "update checker_model_invocation_grant_calls set request_sha256='sha256:" - + "e" * 64 - + "'", + "update checker_model_invocation_grant_calls set request_sha256='sha256:" + "e" * 64 + "'", "delete from checker_model_invocation_grant_calls", "truncate checker_model_invocation_grant_calls", ): diff --git a/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py index 75f7bd962..2e08fe9b7 100644 --- a/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py @@ -1,10 +1,16 @@ """Focused setup and shared-budget PostgreSQL model-usage proofs.""" +from copy import deepcopy +import json + from app.modules.model_usage.models import ModelUsageEffect +from tests.checkers_model_calls.model_usage_pg_support import ( + DatabaseFacts, + model_database as _model_database, +) from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( AsyncSession, BoundaryPorts, - DatabaseFacts, DBAPIError, ModelCallBudgetUnavailable, ModelCallConflict, @@ -17,6 +23,7 @@ canonical_json_hash, evidence, execution, + insert_checker_run, new_record_id, provision, pytest, @@ -28,9 +35,6 @@ text, uuid4, ) -from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( - model_database as _model_database, -) model_database = _model_database @@ -86,9 +90,7 @@ async def publish(spec): first.spec.model_copy( update={ "profile_version": 2, - "settings": ModelGenerationSettings( - reasoning_effort="high", text_verbosity="low" - ), + "settings": ModelGenerationSettings(reasoning_effort="high", text_verbosity="low"), } ) ) @@ -400,6 +402,64 @@ async def test_setup_draft_review_correction_settle_three_shared_calls( assert account.lifetime_settled_nanodollars == settled +async def test_setup_known_failure_settles_once_and_replays_after_restart( + model_database: DatabaseFacts, +) -> None: + """An unusable paid response conserves exact spend and never dispatches twice.""" + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation) + key = await setup_slot_key(model_database, profile) + request_sha = sha("setup-known-failure-request") + response = evidence("setup-known-failure-response", sha("setup-known-failure-response")) + usage = ModelCallUsage( + input_tokens=40, + cached_input_tokens=10, + output_tokens=12, + reasoning_tokens=3, + ) + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = setup_service(session, ports) + prepared = await owner.prepare(key, request_sha) + dispatched = await owner.reserve_dispatch( + prepared, + evidence("setup-known-failure-request", request_sha), + ) + failed = await owner.complete( + dispatched.effect, + provider_response_id="resp_setup_known_failure", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response, + usage=usage, + usable=False, + ) + assert failed.state == "known_failed" + assert failed.replayed is False + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + replay = await setup_service(session, ports).complete( + dispatched.effect, + provider_response_id="resp_setup_known_failure", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response, + usage=usage, + usable=False, + ) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + row = await session.get(ModelUsageEffect, str(dispatched.effect.effect_id)) + assert replay.state == "known_failed" + assert replay.replayed is True + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == 30 * 11 + 10 * 2 + 12 * 29 + assert row.settlement_authorization_evidence_id is not None + assert row.settled_at is not None + + assert ports.events.count("setup_dispatch") == 1 + + async def test_setup_rejects_same_project_profile_substitution_before_effect( model_database: DatabaseFacts, ) -> None: @@ -626,10 +686,7 @@ async def test_fresh_execution_authority_precedes_currentness_and_owner_locks( async with AsyncSession(model_database.engine, expire_on_commit=False) as session: async with session.begin(): - await session.execute( - text("insert into checker_runs(id) values (:id)"), - {"id": run.checker_run_id}, - ) + await insert_checker_run(session, run) owner = service(session, ports) ports.events.clear() await owner.attach_recovery(run, dispatched.effect) @@ -646,6 +703,180 @@ async def test_fresh_execution_authority_precedes_currentness_and_owner_locks( assert ports.events == ["child", "recovery_current"] +async def test_recovery_insert_rejects_every_substituted_owner_and_authority_fact( + model_database: DatabaseFacts, +) -> None: + """The SQL boundary proves a fresh successor and rejects unrelated recovery facts.""" + ports = BoundaryPorts() + profile = await provision(model_database, ports, limit=10_000) + origin = execution(model_database) + paid_slot = slot(model_database, profile, "recovery_guard_origin") + request_sha = sha("recovery-guard-origin") + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + prepared = await owner.prepare_attempt( + origin, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=request_sha, + ) + dispatched = await owner.reserve_dispatch( + origin, + prepared, + evidence("recovery-guard-origin", request_sha), + ) + await owner.mark_uncertain( + dispatched.effect, + sha("recovery-guard-origin-uncertain"), + provider_response_id="resp_recovery_guard_origin", + ) + + other_origin = execution(model_database) + other_slot = slot(model_database, profile, "recovery_guard_other") + other_sha = sha("recovery-guard-other") + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + other_prepared = await owner.prepare_attempt( + other_origin, + other_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=other_sha, + ) + other_dispatched = await owner.reserve_dispatch( + other_origin, + other_prepared, + evidence("recovery-guard-other", other_sha), + ) + await owner.mark_uncertain( + other_dispatched.effect, + sha("recovery-guard-other-uncertain"), + provider_response_id="resp_recovery_guard_other", + ) + + successor = execution(model_database) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await insert_checker_run(session, successor) + attached = await service(session, ports).attach_recovery(successor, dispatched.effect) + assert attached.replayed is False + stored = ( + ( + await session.execute( + text( + "select attempt_id,checker_run_id,worker_lease_id," + "worker_lease_generation,execution_facts,effect_facts," + "authorization_evidence_id from checker_model_usage_recoveries " + "where attempt_id=:effect" + ), + {"effect": dispatched.effect.attempt_id}, + ) + ) + .mappings() + .one() + ) + authority = ( + ( + await session.execute( + text( + "select actor_id,action_id,permission_id,after_facts " + "from audit_events where id=:id" + ), + {"id": stored["authorization_evidence_id"]}, + ) + ) + .mappings() + .one() + ) + + unrelated_run = execution(model_database) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await insert_checker_run(session, unrelated_run) + + base_execution = deepcopy(stored["execution_facts"]) + base_effect = deepcopy(stored["effect_facts"]) + wrong_project = str(new_record_id()) + wrong_lease = str(new_record_id()) + mutations = { + "project": lambda row: row["execution"].__setitem__("project_id", wrong_project), + "run": lambda row: row.__setitem__("run", str(unrelated_run.checker_run_id)), + "lease": lambda row: row.__setitem__("lease", wrong_lease), + "generation": lambda row: row.__setitem__("generation", 99), + "effect": lambda row: ( + row.__setitem__("effect_id", str(other_dispatched.effect.attempt_id)), + row.__setitem__("effect", other_dispatched.effect.model_dump(mode="json")), + ), + "action": lambda row: row.__setitem__("action", "checker.model_call.dispatch"), + "actor": lambda row: row.__setitem__("actor", str(new_record_id())), + "resource_digest": lambda row: row.__setitem__( + "after", {"allowed": True, "resource_context_digest": sha("wrong-recover")} + ), + } + for label, mutate in mutations.items(): + row = { + "effect_id": str(stored["attempt_id"]), + "run": str(stored["checker_run_id"]), + "lease": str(stored["worker_lease_id"]), + "generation": stored["worker_lease_generation"], + "execution": deepcopy(base_execution), + "effect": deepcopy(base_effect), + "actor": authority["actor_id"], + "action": authority["action_id"], + "permission": authority["permission_id"], + "after": authority["after_facts"], + } + mutate(row) + async with AsyncSession(model_database.engine) as session: + with pytest.raises(DBAPIError, match="recovery (ancestry|authority) is invalid"): + async with session.begin(): + if label == "actor": + await session.execute( + text( + "insert into actor_profiles(id,actor_kind,status) " + "values (:id,'human','active')" + ), + {"id": row["actor"]}, + ) + evidence_id = new_record_id() + await session.execute( + text( + "insert into audit_events(id,event_domain,event_type,actor_ref_kind," + "actor_id,action_id,permission_id,denial_code,after_facts) values " + "(:id,'authority','SensitiveAuthorizationAllowed','actor_profile'," + ":actor,:action,:permission,null,cast(:after as json))" + ), + { + "id": evidence_id, + "actor": row["actor"], + "action": row["action"], + "permission": row["permission"], + "after": json.dumps(row["after"]), + }, + ) + await session.execute( + text( + "insert into checker_model_usage_recoveries(" + "id,attempt_id,checker_run_id,worker_lease_id," + "worker_lease_generation,execution_facts,effect_facts," + "authorization_evidence_id) values " + "(:id,:effect,:run,:lease,:generation,cast(:execution as jsonb)," + "cast(:effect_facts as jsonb),:authority)" + ), + { + "id": new_record_id(), + "effect": row["effect_id"], + "run": row["run"], + "lease": row["lease"], + "generation": row["generation"], + "execution": json.dumps(row["execution"]), + "effect_facts": json.dumps(row["effect"]), + "authority": evidence_id, + }, + ) + + async def test_reservation_rollback_and_completion_settle_exactly_once( model_database: DatabaseFacts, ) -> None: @@ -702,10 +933,7 @@ async def test_reservation_rollback_and_completion_settle_exactly_once( async with AsyncSession(model_database.engine, expire_on_commit=False) as session: async with session.begin(): - await session.execute( - text("insert into checker_runs(id) values (:id)"), - {"id": run.checker_run_id}, - ) + await insert_checker_run(session, run) ports.events.clear() attached = await service(session, ports).attach_completed(run, dispatched.effect) assert attached.replayed is False diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d346d6816..43b45a332 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,7 +23,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "5eab828a9afc22547f0b6e4c197ba8e0057290200878b890f2a0225249d0a3d7" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "20996fd2588683c112ca450338c6e6c128b934c6997acf42660788b8152f3af7" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/test_identifier_schema.py b/backend/tests/test_identifier_schema.py index bf7bc513e..079571196 100644 --- a/backend/tests/test_identifier_schema.py +++ b/backend/tests/test_identifier_schema.py @@ -23,6 +23,8 @@ "guide_source_extraction_retry_budgets": ("binding_id",), "outbox_delivery_attempts": ("event_id", "claim_generation"), "project_compensation_units": ("project_id", "instrument_type", "unit_code"), + "model_usage_budget_accounts": ("project_id",), + "checker_model_invocation_grant_calls": ("grant_id", "call_index"), } @@ -38,7 +40,8 @@ def test_every_generated_primary_key_uses_native_uuid_and_v7_constraint(): assert str(key.type.compile(dialect=postgresql.dialect())) == "UUID" constraints = { constraint.name: str(constraint.sqltext) - for constraint in table.constraints if isinstance(constraint, CheckConstraint) + for constraint in table.constraints + if isinstance(constraint, CheckConstraint) } assert constraints[f"ck_{table.name}_{key.name}_uuid7"] == ( f"(get_byte(uuid_send({key.name}), 6) >> 4) = 7 and " @@ -85,7 +88,8 @@ def test_generated_non_primary_operation_identities_have_version_guards(): assert isinstance(table.c[column].type, Uuid) checks = { str(check.name): str(check.sqltext) - for check in table.constraints if isinstance(check, CheckConstraint) + for check in table.constraints + if isinstance(check, CheckConstraint) } expression = checks[f"ck_{owner}_{column}_uuid7"] assert f"(get_byte(uuid_send({column}), 6) >> 4) = 7" in expression diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 29cb99668..49b0f5083 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -1764,7 +1764,7 @@ another paid effect. | `model_usage_budget_accounts` | Current configuration and serialized lifetime reserved/settled integer USD nanodollars; configuration changes never reset spend | | `checker_model_usage_slots` | Stable project, assignment, Submission/version, requirement, registry digest, configuration hash, exact profile and call-slot identity; excludes retry generation and execution lease | | `model_usage_effects` | Immutable first request hash, dispatch intent, reservation, provider identity, private ART request/response references, usage and once-only terminal settlement | -| `checker_model_usage_recoveries` | Fresh current-run attachment to an existing attempt; cannot authorize a new provider create | +| `checker_model_usage_recoveries` | Fresh current-run attachment with canonical execution/effect facts; SQL verifies the exact paid slot, running successor lease, fixed-service recovery receipt and resource digest, and the row cannot authorize a new provider create | | `checker_model_invocation_grants` | Hashed short-lived capability, exact execution and binding, PostgreSQL expiry and one consumption into the selected attempt/request | An attempt progresses from `prepared` to `dispatching`, with uncertain delivery diff --git a/docs/operations_checker_models.md b/docs/operations_checker_models.md index 29e8fa4ce..ebbdf9b66 100644 --- a/docs/operations_checker_models.md +++ b/docs/operations_checker_models.md @@ -157,9 +157,12 @@ project budget. Setup recovery never fabricates checker lineage. The setup handler reads retained verified response ART and completes the original setup effect. Provider storage is -disabled; an ID-less genuinely ambiguous response may remain held indefinitely. -The exact Operator read provides visibility but cannot assert no effect, release -money or create a replacement. +disabled: if delivery succeeds but the process is lost before response ART is +retained, a provider ID alone does not make that response retrievable. That effect, +like an ID-less genuinely ambiguous response, remains uncertain with its reservation +held until authenticated evidence can establish a safe terminal outcome. The exact +Operator read provides visibility but cannot assert no effect, release money or +create a replacement. For POST, budget exhaustion, provider outage, unavailable runtime and invalid provider output remain infrastructure failure on the original Submission. They do From 7a7d52605cc702c47c37d4f4d9448bc4de8495e3 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 23:25:16 +0100 Subject: [PATCH 062/115] refactor(tests): share explicit administrative role expectations --- .ci/auth-boundaries/TEST_STRUCTURE_DEBT.json | 54 +++++++++---------- .../tests/authorization/catalogue_fixtures.py | 43 +++++++++++++++ backend/tests/test_authorization.py | 42 ++------------- 3 files changed, 74 insertions(+), 65 deletions(-) diff --git a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json index 1192f6fbd..d0236eda8 100644 --- a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json +++ b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json @@ -98,11 +98,11 @@ }, { "capability": "oversized_authorization_structure", - "content_sha256": "b89babd7cd41d37a0f2024f9064bbf45af102b9df70981a27e8f208a5e7b7716", - "end_line": 10788, + "content_sha256": "9b3626c6c8932572b1c7a98f05cc8d90be34f757a4ddbef93f6f8569ca200416", + "end_line": 10756, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 10788, + "observed_lines": 10756, "path": "backend/tests/test_authorization.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -243,134 +243,134 @@ { "capability": "oversized_authorization_structure", "content_sha256": "10003810b90ad8ea86434aaae1a38e650c5df4bebd09bb0577ac3ebb7a522351", - "end_line": 2689, + "end_line": 2657, "hard_limit": 120, "kind": "test_function", "observed_lines": 121, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_identity_link_lifecycle_route_preserves_outcome_transaction_contract", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2569 + "start_line": 2537 }, { "capability": "oversized_authorization_structure", "content_sha256": "b4d58c998c9b8b2efedcdd67d1beb2e804c7f6dfca61b4818d6e95d8268c6f08", - "end_line": 6920, + "end_line": 6888, "hard_limit": 120, "kind": "test_function", "observed_lines": 122, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_identity_link_lifecycle_service_applies_success_and_guards_conflicts", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6799 + "start_line": 6767 }, { "capability": "oversized_authorization_structure", "content_sha256": "0858a7b107e68244cd719c9521856944411f88442818ae491ebb58a45ef4f417", - "end_line": 5799, + "end_line": 5767, "hard_limit": 120, "kind": "test_function", "observed_lines": 126, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_actor_authority_crossed_mutations_complete_in_both_orders", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 5674 + "start_line": 5642 }, { "capability": "oversized_authorization_structure", "content_sha256": "56e6f85cc1b712b6410bee986d54e59e4dbad4c3f73c0599f12fe284587577a7", - "end_line": 6116, + "end_line": 6084, "hard_limit": 120, "kind": "test_function", "observed_lines": 312, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_crosses_real_lifecycle_service_transactions", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 5805 + "start_line": 5773 }, { "capability": "oversized_authorization_structure", "content_sha256": "5cbc72df2c822f2f57db164c0ea4aa2f2d76d591313f69d6496fc26b127b581e", - "end_line": 3787, + "end_line": 3755, "hard_limit": 120, "kind": "test_function", "observed_lines": 149, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_11c2_reads_require_exact_admin_context_and_role_allowlist", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 3639 + "start_line": 3607 }, { "capability": "oversized_authorization_structure", "content_sha256": "478d47861e68cfa6571199b0f69785c8f3f5a3483774cee0929b6a13dba6a648", - "end_line": 2132, + "end_line": 2134, "hard_limit": 120, "kind": "test_function", "observed_lines": 302, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_mutation_resources_and_prepared_scopes_are_closed", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1831 + "start_line": 1833 }, { "capability": "oversized_authorization_structure", "content_sha256": "3d2a9843510cdb61b34500aa79a8ed3572df72723ab401f0245300e10c70044e", - "end_line": 9065, + "end_line": 9033, "hard_limit": 120, "kind": "test_function", "observed_lines": 174, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_read_permissions_have_postgresql_role_scope_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8892 + "start_line": 8860 }, { "capability": "oversized_authorization_structure", "content_sha256": "0da4e29b29ebd44bd9049145ee4148531320bce53859ad5341e5f57261e5a884", - "end_line": 10501, + "end_line": 10469, "hard_limit": 120, "kind": "test_function", "observed_lines": 227, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_and_all_operation_mappings_commit_one_linked_pair", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 10275 + "start_line": 10243 }, { "capability": "oversized_authorization_structure", "content_sha256": "8deb42b92c936b62417f3bfe307b4a886612b8e217af8e5abc14a5ae3222452a", - "end_line": 1542, + "end_line": 1544, "hard_limit": 120, "kind": "test_function", "observed_lines": 195, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_mutation_routes_conceal_denials_and_preserve_self_guards_atomically", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1348 + "start_line": 1350 }, { "capability": "oversized_authorization_structure", "content_sha256": "19e0d2a736a60fcd30e5c8e18f0641c6b66ed3a669e2d18dd5784195990d1752", - "end_line": 1778, + "end_line": 1780, "hard_limit": 120, "kind": "test_function", "observed_lines": 218, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_mutation_routes_enforce_project_lifecycle_without_disclosure", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1561 + "start_line": 1563 }, { "capability": "oversized_authorization_structure", "content_sha256": "217ea21f4a2cbd31a4428ba1f01a9fcd90e73ee95fedb2587100a847e8921926", - "end_line": 10030, + "end_line": 9998, "hard_limit": 120, "kind": "test_function", "observed_lines": 154, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_service_actor_replay_fails_closed_on_committed_state_drift", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 9877 + "start_line": 9845 }, { "capability": "oversized_authorization_structure", @@ -411,14 +411,14 @@ { "capability": "oversized_authorization_structure", "content_sha256": "32083a125798736fa17533aaa767c0236ae3042de4f7d8debdce5f15e0a801fe", - "end_line": 9347, + "end_line": 9315, "hard_limit": 100, "kind": "test_helper", "observed_lines": 130, "path": "backend/tests/test_authorization.py", "qualified_symbol": "_operation_success", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 9218 + "start_line": 9186 } ], "exceptions": [], diff --git a/backend/tests/authorization/catalogue_fixtures.py b/backend/tests/authorization/catalogue_fixtures.py index 6dea17bff..b1ebcf808 100644 --- a/backend/tests/authorization/catalogue_fixtures.py +++ b/backend/tests/authorization/catalogue_fixtures.py @@ -1,6 +1,7 @@ """Explicit catalogue expectations, independent of production definitions.""" from app.modules.actors.api import ServiceIdentity +from app.modules.authorization.schemas import AdminRole, AdminScope FIXED_SERVICE_ACTION_EXPECTATIONS = { ServiceIdentity.TASK_REVISION_EXPIRER: {"task.revision.expire", "task.revision.recover"}, @@ -747,3 +748,45 @@ "project_role_grant.revoke", "submission.create", } + + +ADMIN_ROLE_PERMISSION_EXPECTATIONS = { + AdminRole.ACCESS_ADMINISTRATOR: """actor.profile.read_any actor.profile.suspend + actor.profile.reactivate actor.profile.deactivate actor.identity_link.read + actor.identity_link.revoke actor.identity_link.reactivate actor.service.provision + admin_role.read admin_role.grant admin_role.revoke audit.read audit.export""".split(), + AdminRole.OPERATOR: """project.read project.setup_diagnostic.read + project.effective_policy.read review.queue.inspect review.lease.force_release + contribution.read_project operations.status.read operations.timer.run operations.reconcile.run operations.outbox.retry + operations.projection.rebuild operations.task.start_override + operations.submission_gate.repair operations.checker.retry + project.model_profile.manage project.model_budget.manage + project.model_call.recovery_child.authorize artifact.binding.read + artifact.replica.read artifact.receipt.read artifact.verification_job.read + artifact.verification_job.retry artifact.recovery_attempt.read artifact.audit.read + audit.read""".split(), + AdminRole.PROJECT_MANAGER: """project.create project.read project.setup_diagnostic.read + project.effective_policy.read project.update project.archive project.guide.manage + project.guide_compilation.request project.effective_policy.manage project.task.manage + project.review_policy.manage project.role_grant.read project.role_grant.manage + artifact.guide_source.ingest + review.queue.inspect contribution.read_project compensation.award.read + audit.read""".split(), + AdminRole.FINANCE_AUTHORITY: """project.read contribution.read_project + compensation.policy.manage compensation.adapter_binding.manage + compensation.award.read compensation.delivery.reconcile audit.read""".split(), + AdminRole.AUDIT_AUTHORITY: """actor.profile.read_any actor.identity_link.read + admin_role.read project.read project.setup_diagnostic.read + project.effective_policy.read project.role_grant.read review.queue.inspect + review.chain.read contribution.read_project compensation.award.read audit.read + audit.export""".split(), +} + + +ADMIN_ROLE_SCOPE_EXPECTATIONS = { + AdminRole.ACCESS_ADMINISTRATOR: [AdminScope.SYSTEM], + AdminRole.OPERATOR: [AdminScope.SYSTEM], + AdminRole.PROJECT_MANAGER: [AdminScope.SYSTEM, AdminScope.PROJECT], + AdminRole.FINANCE_AUTHORITY: [AdminScope.SYSTEM, AdminScope.PROJECT], + AdminRole.AUDIT_AUTHORITY: [AdminScope.SYSTEM, AdminScope.PROJECT], +} diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 547cd5203..a6f7cb818 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -15,6 +15,8 @@ ) from tests.authorization.catalogue_fixtures import ( + ADMIN_ROLE_PERMISSION_EXPECTATIONS, + ADMIN_ROLE_SCOPE_EXPECTATIONS, ART_CUSTODY_EXPECTATIONS, REV_CUSTODY_EXPECTATIONS, ) @@ -2233,44 +2235,8 @@ def test_fixed_service_action_matrix_rejects_metadata_drift( def test_administrative_role_policy_and_definition_responses_are_exact() -> None: - expected_permissions = { - AdminRole.ACCESS_ADMINISTRATOR: """actor.profile.read_any actor.profile.suspend - actor.profile.reactivate actor.profile.deactivate actor.identity_link.read - actor.identity_link.revoke actor.identity_link.reactivate actor.service.provision - admin_role.read admin_role.grant admin_role.revoke audit.read audit.export""".split(), - AdminRole.OPERATOR: """project.read project.setup_diagnostic.read - project.effective_policy.read review.queue.inspect review.lease.force_release - contribution.read_project operations.status.read operations.timer.run operations.reconcile.run operations.outbox.retry - operations.projection.rebuild operations.task.start_override - operations.submission_gate.repair operations.checker.retry - project.model_profile.manage project.model_budget.manage - project.model_call.recovery_child.authorize artifact.binding.read - artifact.replica.read artifact.receipt.read artifact.verification_job.read - artifact.verification_job.retry artifact.recovery_attempt.read artifact.audit.read - audit.read""".split(), - AdminRole.PROJECT_MANAGER: """project.create project.read project.setup_diagnostic.read - project.effective_policy.read project.update project.archive project.guide.manage - project.guide_compilation.request project.effective_policy.manage project.task.manage - project.review_policy.manage project.role_grant.read project.role_grant.manage - artifact.guide_source.ingest - review.queue.inspect contribution.read_project compensation.award.read - audit.read""".split(), - AdminRole.FINANCE_AUTHORITY: """project.read contribution.read_project - compensation.policy.manage compensation.adapter_binding.manage - compensation.award.read compensation.delivery.reconcile audit.read""".split(), - AdminRole.AUDIT_AUTHORITY: """actor.profile.read_any actor.identity_link.read - admin_role.read project.read project.setup_diagnostic.read - project.effective_policy.read project.role_grant.read review.queue.inspect - review.chain.read contribution.read_project compensation.award.read audit.read - audit.export""".split(), - } - expected_scopes = { - AdminRole.ACCESS_ADMINISTRATOR: [AdminScope.SYSTEM], - AdminRole.OPERATOR: [AdminScope.SYSTEM], - AdminRole.PROJECT_MANAGER: [AdminScope.SYSTEM, AdminScope.PROJECT], - AdminRole.FINANCE_AUTHORITY: [AdminScope.SYSTEM, AdminScope.PROJECT], - AdminRole.AUDIT_AUTHORITY: [AdminScope.SYSTEM, AdminScope.PROJECT], - } + expected_permissions = ADMIN_ROLE_PERMISSION_EXPECTATIONS + expected_scopes = ADMIN_ROLE_SCOPE_EXPECTATIONS assert { role: [permission.value for permission in permissions] From 0e4c9c104452eacb7f230513f6a3599232774db6 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 23:46:58 +0100 Subject: [PATCH 063/115] test(artifacts): use JSON model evidence in shared lifecycle fixtures --- backend/tests/test_artifact_admission.py | 2 +- .../tests/test_artifact_internal_authorization.py | 4 +++- ...ifact_provider_activation_terminal_postgresql.py | 13 +++++++++---- backend/tests/test_artifact_recovery.py | 3 ++- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/backend/tests/test_artifact_admission.py b/backend/tests/test_artifact_admission.py index be1679dec..6718f4421 100644 --- a/backend/tests/test_artifact_admission.py +++ b/backend/tests/test_artifact_admission.py @@ -165,7 +165,7 @@ def admission_database_env(isolated_database_env: str) -> Iterator[str]: def _settings(tmp_path: Path, *, maximum_bytes: int = 1024) -> Settings: durable_root = tmp_path / "durable" - durable_root.mkdir(mode=0o700, parents=True) + durable_root.mkdir(mode=0o700, parents=True, exist_ok=True) return Settings( _env_file=None, **artifact_admission_limit_settings(maximum_bytes), diff --git a/backend/tests/test_artifact_internal_authorization.py b/backend/tests/test_artifact_internal_authorization.py index 556b3b88b..14f113ffa 100644 --- a/backend/tests/test_artifact_internal_authorization.py +++ b/backend/tests/test_artifact_internal_authorization.py @@ -908,7 +908,9 @@ async def test_verification_claim_and_terminal_failures_roll_back_both_sides( session.add_all((*resolver, *verifier)) await session.commit() relationships = await seed_checker_output_relationships(session, namespace) - async with minted_source(tmp_path / "atomic-verify", b"verified") as source: + async with minted_source( + tmp_path / "atomic-verify", b'{"verified":true}', media_type="application/json" + ) as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) assert ( diff --git a/backend/tests/test_artifact_provider_activation_terminal_postgresql.py b/backend/tests/test_artifact_provider_activation_terminal_postgresql.py index 68d6e1372..7de1d9772 100644 --- a/backend/tests/test_artifact_provider_activation_terminal_postgresql.py +++ b/backend/tests/test_artifact_provider_activation_terminal_postgresql.py @@ -316,7 +316,8 @@ async def test_checker_output_shared_put_and_verification_lifecycle( async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) async with minted_source( - tmp_path / f"checker-{expected_outcome}", b"checker lifecycle" + tmp_path / f"checker-{expected_outcome}", b'{"output":"checker lifecycle"}', + media_type="application/json", ) as source: project_id, task_id, checker_run_id, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) @@ -409,7 +410,8 @@ async def test_checker_output_put_observation_terminal_outcomes( async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) async with minted_source( - tmp_path / f"checker-observation-{expected_outcome}", b"checker observation" + tmp_path / f"checker-observation-{expected_outcome}", b'{"output":"checker observation"}', + media_type="application/json", ) as source: _project_id, _task_id, checker_run_id, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) @@ -526,7 +528,8 @@ async def test_expired_activation_rolls_back_provider_verification_facts( ) async with minted_source( tmp_path / f"expired-{provider_outcome}", - b"provider terminal evidence", + b'{"output":"provider terminal evidence"}', + media_type="application/json", ) as source: _project_id, _task_id, _checker_run_id, admission = await _admit_checker_output( session, @@ -748,7 +751,9 @@ async def test_verification_unavailable_retries_then_exhausts( try: async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / "unavailable", b"retry") as source: + async with minted_source( + tmp_path / "unavailable", b'{"output":"retry"}', media_type="application/json" + ) as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) orchestrator = ArtifactStorageOrchestrator( diff --git a/backend/tests/test_artifact_recovery.py b/backend/tests/test_artifact_recovery.py index 471cdad94..cafc6818c 100644 --- a/backend/tests/test_artifact_recovery.py +++ b/backend/tests/test_artifact_recovery.py @@ -192,7 +192,8 @@ async def _exhausted_job(session, settings, tmp_path, context): ) relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) async with minted_source( - tmp_path / "checker-output", b"recover checker output", limits=limits, + tmp_path / "checker-output", b'{"output":"recover checker output"}', + media_type="application/json", limits=limits, ) as source: project_id, task_id, _checker_run_id, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) From 6f881aa98fd8f70be27b71e391ed003be686ad4f Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 00:00:45 +0100 Subject: [PATCH 064/115] fix(tests): align guide runtime fixture capabilities --- .../projects/guide_compilation/helpers.py | 150 +++++++++--------- .../guide_compilation/proposals/pg_support.py | 9 +- .../proposals/test_public_dispatch.py | 17 +- .../guide_compilation/runtime_fixtures.py | 60 ++++--- .../test_neutral_document_tools.py | 6 +- backend/tests/test_alembic.py | 3 +- 6 files changed, 146 insertions(+), 99 deletions(-) diff --git a/backend/tests/projects/guide_compilation/helpers.py b/backend/tests/projects/guide_compilation/helpers.py index f71bc86d1..0ebde2e40 100644 --- a/backend/tests/projects/guide_compilation/helpers.py +++ b/backend/tests/projects/guide_compilation/helpers.py @@ -7,7 +7,7 @@ import json from uuid import UUID, uuid4 -from sqlalchemy import text +from sqlalchemy import insert, text, update from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, @@ -459,84 +459,89 @@ async def _seed_artifact_custody( ) -> None: from datetime import datetime, timezone - namespace_row = ArtifactStorageNamespace( - id="primary", - backend=namespace.backend if namespace else "local", - adapter=namespace.adapter if namespace else "local", - provider_profile=namespace.provider_profile if namespace else "test", - namespace_descriptor=namespace.namespace_descriptor + namespace_values = { + "id": "primary", + "backend": namespace.backend if namespace else "local", + "adapter": namespace.adapter if namespace else "local", + "provider_profile": namespace.provider_profile if namespace else "test", + "namespace_descriptor": namespace.namespace_descriptor if namespace else {"root": "guide-compilation-fixture"}, - namespace_fingerprint=namespace.namespace_fingerprint if namespace else SOURCE_SHA256, + "namespace_fingerprint": namespace.namespace_fingerprint + if namespace + else SOURCE_SHA256, + } + provider_ref = provider_object_ref or f"fixtures/{CONTENT_ID}" + # Explicit Core statements let historical-migration tests arrange the ART + # columns that existed at that revision without making current ORM metadata + # project newer nullable columns into the older schema. + await session.execute(insert(ArtifactStorageNamespace).values(**namespace_values)) + await session.execute( + insert(ArtifactContent).values( + id=str(CONTENT_ID), + sha256=SOURCE_SHA256, + byte_count=len(SOURCE_BYTES), + media_type="application/pdf", + normalized_display_name="guide.pdf", + ) ) - session.add_all( - [ - namespace_row, - ArtifactContent( - id=str(CONTENT_ID), - sha256=SOURCE_SHA256, - byte_count=len(SOURCE_BYTES), - media_type="application/pdf", - normalized_display_name="guide.pdf", - ), - GuideSourceArtifactIngest( - id=str(DOCUMENT_VERSION_ID), - source_item_id=str(SOURCE_ITEM_ID), - actor_profile_id=str(values["actor"]), - sha256=SOURCE_SHA256, - byte_count=len(SOURCE_BYTES), - media_type="application/pdf", - ), - ] + await session.execute( + insert(GuideSourceArtifactIngest).values( + id=str(DOCUMENT_VERSION_ID), + source_item_id=str(SOURCE_ITEM_ID), + actor_profile_id=str(values["actor"]), + sha256=SOURCE_SHA256, + byte_count=len(SOURCE_BYTES), + media_type="application/pdf", + ) ) - await session.flush() - replica = ArtifactReplica( - id=str(REPLICA_ID), - content_id=str(CONTENT_ID), - storage_namespace_id="primary", - namespace_fingerprint=namespace_row.namespace_fingerprint, - adapter=namespace_row.adapter, - provider_profile=namespace_row.provider_profile, - provider_object_ref=provider_object_ref or f"fixtures/{CONTENT_ID}", - verification_state="pending", - availability_state="unknown", - integrity_state="unknown", + await session.execute( + insert(ArtifactReplica).values( + id=str(REPLICA_ID), + content_id=str(CONTENT_ID), + storage_namespace_id="primary", + namespace_fingerprint=namespace_values["namespace_fingerprint"], + adapter=namespace_values["adapter"], + provider_profile=namespace_values["provider_profile"], + provider_object_ref=provider_ref, + verification_state="pending", + availability_state="unknown", + integrity_state="unknown", + ) ) - session.add(replica) - await session.flush() - put = ArtifactPutAttempt( - id=str(PUT_ATTEMPT_ID), - producer_request_type="guide", - producer_type="actor_profile", - producer_ref=str(values["actor"]), - project_id=str(values["project"]), - guide_source_item_id=str(SOURCE_ITEM_ID), - sha256=SOURCE_SHA256, - byte_count=len(SOURCE_BYTES), - media_type="application/pdf", - storage_namespace_id="primary", - namespace_fingerprint=namespace_row.namespace_fingerprint, - canonical_target=f"sha256/{SOURCE_SHA256[7:9]}/{SOURCE_SHA256[9:]}", - operation_identity=SOURCE_SHA256, - request_digest=SOURCE_SHA256, - status="object_confirmed", - terminal_result_code="document_stored", - replica_id=str(REPLICA_ID), - terminal_at=datetime.now(timezone.utc), + await session.execute( + insert(ArtifactPutAttempt).values( + id=str(PUT_ATTEMPT_ID), + producer_request_type="guide", + producer_type="actor_profile", + producer_ref=str(values["actor"]), + project_id=str(values["project"]), + guide_source_item_id=str(SOURCE_ITEM_ID), + sha256=SOURCE_SHA256, + byte_count=len(SOURCE_BYTES), + media_type="application/pdf", + storage_namespace_id="primary", + namespace_fingerprint=namespace_values["namespace_fingerprint"], + canonical_target=f"sha256/{SOURCE_SHA256[7:9]}/{SOURCE_SHA256[9:]}", + operation_identity=SOURCE_SHA256, + request_digest=SOURCE_SHA256, + status="object_confirmed", + terminal_result_code="document_stored", + replica_id=str(REPLICA_ID), + terminal_at=datetime.now(timezone.utc), + ) ) - session.add(put) - await session.flush() - session.add( - ArtifactOperationReceipt( + await session.execute( + insert(ArtifactOperationReceipt).values( id=str(RECEIPT_ID), contract_version=2, - put_attempt_id=put.id, + put_attempt_id=str(PUT_ATTEMPT_ID), guide_source_item_id=str(SOURCE_ITEM_ID), - replica_id=replica.id, + replica_id=str(REPLICA_ID), operation="put", - idempotency_key=put.operation_identity, - request_digest=put.request_digest, - provider_object_ref=replica.provider_object_ref, + idempotency_key=SOURCE_SHA256, + request_digest=SOURCE_SHA256, + provider_object_ref=provider_ref, replayed=False, outcome="document_stored", attempt_number=1, @@ -544,8 +549,11 @@ async def _seed_artifact_custody( details=[], ) ) - await session.flush() - put.receipt_id = str(RECEIPT_ID) + await session.execute( + update(ArtifactPutAttempt) + .where(ArtifactPutAttempt.id == str(PUT_ATTEMPT_ID)) + .values(receipt_id=str(RECEIPT_ID)) + ) async def seed_database( diff --git a/backend/tests/projects/guide_compilation/proposals/pg_support.py b/backend/tests/projects/guide_compilation/proposals/pg_support.py index 68e0db44f..cd72922c4 100644 --- a/backend/tests/projects/guide_compilation/proposals/pg_support.py +++ b/backend/tests/projects/guide_compilation/proposals/pg_support.py @@ -255,7 +255,7 @@ async def request_corrected_attempt(factory, actor, correction, *, capabilities= from tests.checkers.external.support import capability_projection from app.modules.projects.guide_compilation.request_inputs import CompilationRequestInputs from app.modules.projects.guide_compilation.service import GuideCompilationService - from tests.projects.guide_compilation.helpers import runtime_configuration + from tests.projects.guide_compilation.helpers import manager_runtime_configuration ctx = HumanAuthorizationContext( actor_profile_id=actor.actor_profile_id, @@ -276,7 +276,7 @@ async def request_corrected_attempt(factory, actor, correction, *, capabilities= request_inputs=CompilationRequestInputs( guide_document_manifest_port(session), capabilities or capability_projection(), - runtime_configuration(), + manager_runtime_configuration(), ), ) return await service.request_correction( @@ -310,7 +310,8 @@ async def finalize_corrected_attempt(factory, values, actor, correction, *, outc await request_corrected_attempt(factory, actor, correction, capabilities=capabilities) async with factory() as session: setup = await session.get(ProjectSetupRun, str(correction.successor_setup_run_id)) - runtime = ScriptedGuideRuntime() + configuration = manager_runtime_configuration() + runtime = ScriptedGuideRuntime(configuration) if outcome is not None: runtime.outcome = outcome @@ -326,7 +327,7 @@ async def no_model_session(_state, _context): checker_capabilities=capabilities, request_authority=guide_compilation_request_authority, execution_authority=guide_compilation_execution_authority, - configuration_factory=manager_runtime_configuration, + configuration_factory=lambda: configuration, model_profiles=lambda _session: None, runtime_factory=lambda configuration: runtime, sufficiency_authorization_factory=guide_sufficiency_projection_authorization, diff --git a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py index 4bf2d2ba7..e5913b531 100644 --- a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py +++ b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py @@ -10,6 +10,8 @@ from app.modules.projects.api.setup_identity import project_guide_compilation_task_id from tests.projects.guide_compilation.runtime_fixtures import ( ScriptedGuideRuntime, + configure_scripted_model_transport, + publish_scripted_model_profile, scripted_document_access_runtime, scripted_guide_document_parser_factory, scripted_runtime_configuration, @@ -38,7 +40,7 @@ async def assert_other_actors_cannot_recover(factory, creator, latest_path, endp @pytest.mark.parametrize("broker_failure", [False, True]) @pytest.mark.parametrize("manager_handoff", [False, True]) async def test_manual_dispatch_retains_one_human_request_and_one_execution( - isolated_database_env, monkeypatch, broker_failure, manager_handoff, + isolated_database_env, monkeypatch, tmp_path, broker_failure, manager_handoff, ): from app.core.config import get_settings from app.core import project_agents @@ -46,6 +48,7 @@ async def test_manual_dispatch_retains_one_human_request_and_one_execution( monkeypatch.setenv("WORKSTREAM_CELERY_BROKER_URL", "memory://") monkeypatch.setenv("WORKSTREAM_CELERY_RESULT_BACKEND_URL", "cache+memory://") + configure_scripted_model_transport(monkeypatch, tmp_path / "unused-model-proxy.sock") get_settings.cache_clear() from app.workers import project_setup as worker monkeypatch.setattr(project_agents, "project_guide_runtime_configuration", scripted_runtime_configuration) @@ -64,6 +67,12 @@ def enqueue(**values): return values["task_id"] monkeypatch.setattr(setup_queue, "enqueue_project_guide_compilation", enqueue) async with proposal_case(isolated_database_env) as (_, factory, command, actor, grant): + from tests.project_create_fixtures import grant_fixture_admin_role + + async with factory() as session, session.begin(): + await grant_fixture_admin_role( + session, actor.actor_profile_id, role="operator", scope="system" + ) path = proposal_path(command) latest_path = f"/api/v1/projects/{command.project_id}/guides/{command.guide_id}/setup-runs/latest" async def predecessor_record(): @@ -73,6 +82,12 @@ async def predecessor_record(): ), {"id": command.compilation_id}) original_finalization = await predecessor_record() async with proposal_client(factory, actor) as client: + configuration = scripted_runtime_configuration( + get_settings(), adapter_profile_builder=lambda value: value + ) + await publish_scripted_model_profile( + client, command.project_id, configuration + ) package = await client.get(path+"/proposal") assert package.status_code == 200, package.text correction = await client.post(path+"/corrections", headers={"Idempotency-Key":str(uuid4())}, json={ diff --git a/backend/tests/projects/guide_compilation/runtime_fixtures.py b/backend/tests/projects/guide_compilation/runtime_fixtures.py index 96bc8a08b..7872baf48 100644 --- a/backend/tests/projects/guide_compilation/runtime_fixtures.py +++ b/backend/tests/projects/guide_compilation/runtime_fixtures.py @@ -102,16 +102,50 @@ def scripted_document_access_runtime( return document_access(attempt_id, manifest, configuration) +def scripted_model_profile_spec(project_id, configuration) -> ProjectModelProfileSpec: + """Build the bounded profile required by the setup runtime's fixed tools.""" + source = profile_spec().model_dump(mode="python") + source.update( + project_id=project_id, + profile_version=1, + provider=configuration.model_provider, + api=configuration.model_api, + model_snapshot=configuration.model, + allowed_tools=("read_document", "search_document"), + maximum_tool_calls=configuration.maximum_tool_calls, + maximum_tool_output_bytes=configuration.maximum_tool_output_characters * 4, + ) + return ProjectModelProfileSpec.model_validate(source) + + +async def publish_scripted_model_profile(client, project_id, configuration): + """Publish one exact scripted-runtime profile through the Operator API.""" + spec = scripted_model_profile_spec(project_id, configuration) + published = await client.post( + f"/api/v1/operations/projects/{project_id}/model-profiles", + headers=auth_headers(), + json=spec.model_dump(mode="json"), + ) + assert published.status_code == 201, published.text + + +def configure_scripted_model_transport(monkeypatch, socket_path: Path) -> None: + """Enable the typed setup-model session without providing a live transport.""" + assert socket_path.is_absolute() and not socket_path.exists() + monkeypatch.setenv("WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET", str(socket_path)) + get_settings.cache_clear() + + @pytest.fixture -async def scripted_model_profile(automatic_source, project_client, monkeypatch): +async def scripted_model_profile(automatic_source, project_client, monkeypatch, tmp_path): """Publish the exact inference profile selected by live coordinator tests.""" + configure_scripted_model_transport(monkeypatch, tmp_path / "unused-model-proxy.sock") factory, _actor, setup_id, _snapshot = automatic_source async with factory() as session: project_id = await session.scalar( select(ProjectSetupRun.project_id).where(ProjectSetupRun.id == str(setup_id)) ) assert project_id is not None - set_dev_actor( monkeypatch, roles="viewer", @@ -151,21 +185,7 @@ async def scripted_model_profile(automatic_source, project_client, monkeypatch): configuration = scripted_runtime_configuration( get_settings(), adapter_profile_builder=lambda value: value ) - source = profile_spec().model_dump(mode="python") - source.update( - project_id=project_id, - profile_version=1, - provider=configuration.model_provider, - api=configuration.model_api, - model_snapshot=configuration.model, - ) - spec = ProjectModelProfileSpec.model_validate(source) - published = await project_client.post( - f"/api/v1/operations/projects/{project_id}/model-profiles", - headers=auth_headers(), - json=spec.model_dump(mode="json"), - ) - assert published.status_code == 201, published.text + await publish_scripted_model_profile(project_client, project_id, configuration) set_dev_actor( monkeypatch, roles="project_manager", @@ -209,8 +229,10 @@ async def record_attempt_document_access(sessions, attempt_id, context): class ScriptedGuideRuntime: outcome = generated_result() - identity = runtime_configuration().adapter_identity - calls = 0 + + def __init__(self, configuration=None): + self.identity = (configuration or runtime_configuration()).adapter_identity + self.calls = 0 def admit_execution(self): pass diff --git a/backend/tests/projects/guide_compilation/test_neutral_document_tools.py b/backend/tests/projects/guide_compilation/test_neutral_document_tools.py index a777ea5c3..e80a93127 100644 --- a/backend/tests/projects/guide_compilation/test_neutral_document_tools.py +++ b/backend/tests/projects/guide_compilation/test_neutral_document_tools.py @@ -287,12 +287,14 @@ async def test_grant_or_custody_failure_returns_no_successful_receipt(): assert corrupt_custody.receipts == [] -def test_runtime_capability_exposes_only_tools(): +def test_runtime_capability_exposes_only_bounded_tools_and_model_ports(): tools, _handle, _document, _custody = _tools() capabilities = GuideRuntimeCapabilities(tools=tools) - assert tuple(capabilities.__dataclass_fields__) == ("tools",) + assert tuple(capabilities.__dataclass_fields__) == ("tools", "model") + assert capabilities.model is None assert not hasattr(capabilities, "documents") assert not hasattr(capabilities, "resources") + assert not hasattr(capabilities, "credentials") def test_parser_source_limit_rejects_manifest_before_any_tool_or_inference_call(): diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 7d4570443..ba5b8acb3 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -114,8 +114,7 @@ async def _seed_retained_guide_runtime_rows( _historical_configuration, ) - async with _historical_artifact_model_usage_projection(database_url): - values = await seed_database(database_url) + values = await seed_database(database_url) compilation = context(values) attempt_identity = identity(compilation) document = compilation.material.documents[0] From a65b69897d3d10d8b9efaadb136f52d59ee9b002 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 00:02:28 +0100 Subject: [PATCH 065/115] docs(model): reconcile shared setup and checker custody contracts --- docs/architecture_data_model.md | 27 ++++++++++++++++++--------- docs/operations_roles_permissions.md | 4 +++- docs/spec_authorization_service.md | 16 ++++++++++------ 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 49b0f5083..a29c309e0 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -1750,22 +1750,28 @@ request, run, lease, result, material and original execution receipt. Unprovable retained receipts cause an atomic upgrade refusal; queued work is preserved. Production uses real action-specific AUTH/PREP with fresh live identity checks. -## Checker model calls +## Shared model calls -Model-enabled external POST images select one immutable profile through the -locked policy configuration. These records track paid provider effects separately -from `CheckerRun`: losing a execution lease or retrying a checker does not create +Setup agents and model-enabled external POST images share immutable project +profiles and one lifetime budget account. Setup snapshots its selected profile +into the compilation attempt; POST selects it through the locked policy +configuration. Paid effects retain distinct setup or checker ancestry. Setup +restart, loss of an execution lease, or checker retry does not itself authorize another paid effect. | Table | Custody | |---|---| -| `model_usage_profiles` | Immutable project/image/provider/model/bounds/pricing specification and Operator authorization receipt | +| `model_usage_profiles` | Immutable project/provider/model/bounds/pricing specification and Operator authorization receipt; image/configuration binding remains with approved POST policy | | `model_usage_budget_configurations` | Immutable versions of the finite project lifetime ceiling | | `model_usage_budget_accounts` | Current configuration and serialized lifetime reserved/settled integer USD nanodollars; configuration changes never reset spend | +| `checker_model_usage_envelopes` | Finite aggregate call/token/byte/tool bounds for the locked POST workload and profile | +| `setup_model_usage_envelopes` | Finite aggregate bounds across draft/review/correction for one compilation attempt, setup generation, runtime configuration and profile | +| `setup_model_usage_slots` | Stable setup envelope, phase and call index with the immutable first request hash | | `checker_model_usage_slots` | Stable project, assignment, Submission/version, requirement, registry digest, configuration hash, exact profile and call-slot identity; excludes retry generation and execution lease | -| `model_usage_effects` | Immutable first request hash, dispatch intent, reservation, provider identity, private ART request/response references, usage and once-only terminal settlement | +| `model_usage_effects` | Exactly one strong setup-slot or checker-slot owner; immutable first request hash, dispatch intent, reservation, provider identity, private ART request/response references, usage and once-only terminal settlement | | `checker_model_usage_recoveries` | Fresh current-run attachment with canonical execution/effect facts; SQL verifies the exact paid slot, running successor lease, fixed-service recovery receipt and resource digest, and the row cannot authorize a new provider create | -| `checker_model_invocation_grants` | Hashed short-lived capability, exact execution and binding, PostgreSQL expiry and one consumption into the selected attempt/request | +| `checker_model_invocation_grant_calls` | Each invocation call index binds the exact request hash and durable paid effect; a successor invocation may reuse that same effect without a new paid create | +| `checker_model_invocation_grants` | Hashed short-lived capability, exact execution and binding, PostgreSQL expiry and bounded indexed call attachments | An attempt progresses from `prepared` to `dispatching`, with uncertain delivery retaining its full reservation. Authenticated output and complete usage settle @@ -1780,8 +1786,11 @@ Migration `0061_model_usage` extends existing ART put/receipt custody with `model_call_attempt_id` and exact private request/response slots. Transcript bytes stay in ART, never these ledger rows or outbox payloads. Existing historical run-bound ART rows remain retained; new model evidence uses the paid attempt. -The call ledger's private evidence is separate from normalized public checker -findings. See [model runtime operations](operations_checker_models.md). +Setup and POST request/response bindings use `setup_model_call` and +`checker_model_call`, respectively. Their private evidence stays separate from +normalized public checker findings. Setup uses provider storage disabled: an +observed provider ID alone cannot recover lost response bytes; unresolved effects +retain their reservation until authenticated evidence resolves them. See [model runtime operations](operations_checker_models.md). ## CheckerResult diff --git a/docs/operations_roles_permissions.md b/docs/operations_roles_permissions.md index 4697e75fb..9140cf47e 100644 --- a/docs/operations_roles_permissions.md +++ b/docs/operations_roles_permissions.md @@ -165,9 +165,11 @@ Recovery is a separate, reasoned, audited path. | Submission gate repair (planned; unavailable) | `operations.submission_gate.repair` | Operator | | Checker recovery-source discovery (active public read) | `operations.status.read` via `operations.checker_retry_source.list` | System Operator for one exact project | | Checker terminal-failure retry (active public operation) | `operations.checker.retry` | System Operator under exact terminal-failure guards | -| Immutable project model profile provisioning | `project.model_profile.manage` via `project.model_profile.provision` | System Operator for the exact project and registered image | +| Immutable project model profile provisioning | `project.model_profile.manage` via `project.model_profile.provision` | System Operator for the exact project; approved POST policy separately binds the registered image | | Lifetime project model budget configuration | `project.model_budget.manage` via `project.model_budget.configure` | System Operator; preserves all outstanding reservations and settled spend | | Exact project model-effect inspection | `project.model_budget.manage` via `project.model_usage.read` | System Operator for one exact project/effect; redacted accounting and evidence identities only | +| Setup model preparation and dispatch (active internal operations) | `project.guide_compilation.model.prepare`, `project.guide_compilation.model.dispatch` | Exact fixed service `workstream.project.setup`; current compilation attempt and snapshotted runtime/profile | +| Setup model observation and completion (active internal operations) | `project.guide_compilation.model.observe`, `project.guide_compilation.model.complete` | Exact fixed service `workstream.project.setup`; retained paid effect and bounded evidence, with no new dispatch authority | | Model-call recovery child authorization | `project.model_call.recovery_child.authorize` | System Operator for an exact proven no-effect or fully settled failed parent; uncertain delivery is ineligible | | Review lease force release | `review.lease.force_release` | Operator under WS-REV-001 | | Checker revision-context repair (active public operation) | `project.task.manage` via `project.task.revision.repair` | Covered Project Manager | diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 2c62ab482..d5441d186 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -296,8 +296,8 @@ evaluator integration and activation. ART never writes availability. AUTH-12, AUTH-14, and AUTH-15 are not alternate artifact activation paths. The initial list above contains 75 approved `PermissionId` values. The current -closed catalogue contains 91 permissions, including the nine model-runtime -permissions described below. `ActionId` values are a separate +closed catalogue contains 95 permissions, including the thirteen shared setup/POST +model-runtime permissions described below. `ActionId` values are a separate closed registry layer and are not included in that permission count. AUTH-05A's typed and PostgreSQL audit registry accepts the exact historical 49. The three approved Operator recovery identifiers, 16 artifact identifiers, @@ -673,13 +673,13 @@ remain planned and unavailable, and add no migration. | `artifact.guide_source.read` | `artifact.guide_source.read` | fixed guide-reader service | exact committed document version and fenced compilation attempt | `03` | | `artifact.submission_bundle.prepare` | `submission.create` | assigned contributor | exact task/admission context | `04C2` | | `artifact.submission.binding.create` | `artifact.binding.create` | fixed binding service | submission | `05` | -| `artifact.checker_output.binding.create` | `artifact.binding.create` | fixed binding service | exact paid model-call attempt, private request/response slot and verified content | `06B`, model runtime | +| `artifact.checker_output.binding.create` | `artifact.binding.create` | fixed binding service | exact setup or POST paid model-call attempt, private request/response slot and verified content | `06B`, model runtime | | `artifact.verification.execute` | `artifact.verification.execute` | fixed verifier service | verification job | `02D` | | `artifact.pending_work.scan` | `artifact.pending_work.scan` | fixed scheduler service | system pending-work scope | `02D` | | `artifact.put_attempt.resolve` | `artifact.put_attempt.resolve` | fixed put-resolver service | put attempt | `02D` | | `artifact.pre_submit.checker_input.materialize` | `artifact.checker_input.materialize` | fixed materializer service | exact task, assignment, project/guide/snapshot and locked policy lineage plus plan/catalogue/archive/semantic-manifest identities and current process-local prepared generation; no scratch path/handle is serialized | `04B2/04B3` + `XINT-002-06A` | | `artifact.post_submit.checker_input.materialize` | `artifact.checker_input.materialize` | fixed materializer service | checker run and immutable bindings | `06A` | -| `artifact.checker_output.write` | `artifact.checker_output.write` | fixed checker-output service | exact paid model-call attempt and private request/response slot; request requires current execution, late response requires retained effect custody | `06B`, model runtime | +| `artifact.checker_output.write` | `artifact.checker_output.write` | fixed checker-output service | exact setup or POST paid model-call attempt and private request/response slot; request requires current owner authority, late response requires retained effect custody | `06B`, model runtime | | `artifact.review_packet.materialize` | `artifact.review_packet.materialize` | fixed materializer service | exact active lease and Submission packet | `07A` | | `artifact.review_evidence.binding.create` | `artifact.binding.create` | fixed binding service | future REV-owned evidence slot; no approved v0.1 activation | future | @@ -700,7 +700,7 @@ closed: | `workstream.artifact.guide_reader` | `artifact.guide_source.read` | | `workstream.artifact.materializer` | `artifact.pre_submit.checker_input.materialize`, `artifact.post_submit.checker_input.materialize`, `artifact.review_packet.materialize` | | `workstream.artifact.checker_output` | `artifact.checker_output.write` | -| `workstream.project.setup` | `project.guide_compilation.request_automatic`, `project.guide_compilation.execute`, `project.guide_sufficiency.run`, `project.submission_artifact_policy.derive`, `project.post_submit_checker_policy.derive`, `project.setup_run.update` | +| `workstream.project.setup` | `project.guide_compilation.request_automatic`, `project.guide_compilation.execute`, `project.guide_compilation.model.prepare`, `project.guide_compilation.model.dispatch`, `project.guide_compilation.model.observe`, `project.guide_compilation.model.complete`, `project.guide_sufficiency.run`, `project.submission_artifact_policy.derive`, `project.post_submit_checker_policy.derive`, `project.setup_run.update` | | `workstream.review.preference_expiry` | `review.preference_expiry.run` | | `workstream.review.lease_expiry` | `review.lease_expiry.run` | | `workstream.review.authority_invalidation_reconciliation` | `review.reconcile.run` | @@ -794,6 +794,10 @@ exact fixed-service authority and manifest. | `task.revision.recover` | `workstream.task.revision_expirer` | Exact retained routing manifest, assigned contributor, locked task context, request digest and optional preparation ID/digest pair; fresh fixed-service authority. Recovery retains the source and exact running/paused clock; exact already-scheduled work is a no-op | PILOT-08 implements bounded retained-source recovery of missing preparation or expiry intent; it may freeze unchanged or blocked context, never authorize an automatic rebase or reset the window | | `checker.post_submit.execute` | `workstream.checker.post_submit` | Immutable Submission/request/generation, locked compiled policy, attempt and admitted service; exact pre-I/O authority | Implemented by ARCH-04C/04D2 and invoked by the registered selected request handler | | `checker.post_submit.finalize` | `workstream.checker.post_submit` | Exact execution request/fence, accepted result digest, retained material and original execute receipt; fresh post-I/O authority and atomic evidence; current outputs are empty | Implemented by ARCH-04C/04D2 and invoked by the registered selected request handler | +| `project.guide_compilation.model.prepare` | `workstream.project.setup` | Exact compilation attempt, project/guide/snapshot, setup generation, runtime/profile identity, phase/call index and canonical request hash; fresh setup currentness before MODEL custody | Shared model runtime | +| `project.guide_compilation.model.dispatch` | `workstream.project.setup` | Prepared setup effect and verified request evidence; fresh attempt authority and atomic reservation in the shared lifetime project account before provider I/O | Shared model runtime | +| `project.guide_compilation.model.observe` | `workstream.project.setup` | Exact retained setup effect and provider observation digest; records ambiguous delivery without authorizing a new create | Shared model runtime | +| `project.guide_compilation.model.complete` | `workstream.project.setup` | Exact retained setup effect, terminal digest, verified response evidence and complete usage; settlement or proven no-effect release cannot authorize another dispatch | Shared model runtime | | `checker.model_call.grant` | `workstream.checker.post_submit` | Exact current POST execution, locked project/image/profile, stable paid slot, invocation request digest and private token hash; PostgreSQL lease expiry and single consumption | Project model runtime | | `checker.model_call.prepare` | `workstream.checker.post_submit` | Current POST execution and stable paid slot with immutable first request hash; AUTH then TASK then CHECKERS custody | Project model runtime | | `checker.model_call.dispatch` | `workstream.checker.post_submit` | Current execution, prepared attempt and verified request evidence; atomic lifetime budget reservation and dispatch intent before provider I/O | Project model runtime | @@ -818,7 +822,7 @@ immutable specification or parent receipt; idempotent replay rechecks authority. | ActionId | PermissionId | Exact target | |---|---|---| -| `project.model_profile.provision` | `project.model_profile.manage` | Project, exact registered image and immutable provider/model/bounds/pricing profile | +| `project.model_profile.provision` | `project.model_profile.manage` | Project and immutable provider/model/bounds/pricing profile; POST image binding is separately validated through the approved policy | | `project.model_budget.configure` | `project.model_budget.manage` | Project and immutable budget configuration version; lifetime reservations and settled spend are never reset | | `project.model_usage.read` | `project.model_budget.manage` | One exact paid effect in the project; redacted owner, budget, usage and ART evidence identities only | | `project.model_call.recovery_child.authorize` | `project.model_call.recovery_child.authorize` | Exact current execution and terminal parent receipt; only proven no-effect or fully settled known failure permits a child | From 5fd5b9a7fab98c4edea298b9ad3b1c85f7bec47f Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 03:10:28 +0100 Subject: [PATCH 066/115] fix(test): bind scripted profile publication key --- backend/tests/projects/guide_compilation/runtime_fixtures.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/tests/projects/guide_compilation/runtime_fixtures.py b/backend/tests/projects/guide_compilation/runtime_fixtures.py index 7872baf48..8b128607c 100644 --- a/backend/tests/projects/guide_compilation/runtime_fixtures.py +++ b/backend/tests/projects/guide_compilation/runtime_fixtures.py @@ -8,6 +8,7 @@ from contextlib import asynccontextmanager from io import BytesIO from pathlib import Path +from uuid import uuid4 import pytest from sqlalchemy import select @@ -123,7 +124,7 @@ async def publish_scripted_model_profile(client, project_id, configuration): spec = scripted_model_profile_spec(project_id, configuration) published = await client.post( f"/api/v1/operations/projects/{project_id}/model-profiles", - headers=auth_headers(), + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, json=spec.model_dump(mode="json"), ) assert published.status_code == 201, published.text From 75a35943a41909d6a3a2464e2585fec0e658422c Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 10:41:06 +0100 Subject: [PATCH 067/115] fix(model-usage): bind terminal and retry recovery custody --- backend/alembic/versions/0061_model_usage.py | 133 +++++- backend/app/modules/model_usage/api.py | 14 +- backend/app/modules/model_usage/models.py | 1 - .../app/modules/model_usage/owner_ports.py | 8 +- backend/app/modules/model_usage/service.py | 3 +- backend/scripts/test_lane_catalogue.py | 1 + .../tests/checker_output_admission_helpers.py | 11 +- .../model_usage_pg_support.py | 2 +- .../test_composed_execution.py | 20 +- ...test_model_call_transactions_postgresql.py | 185 ++++---- ...test_model_invocation_grants_postgresql.py | 107 ++++- .../test_model_shared_setup_postgresql.py | 27 ++ ...est_model_terminal_authority_postgresql.py | 221 ++++++++++ backend/tests/test_checker_output_storage.py | 397 +++++++++++------- backend/tests/test_ci_lane_catalogue.py | 9 +- 15 files changed, 867 insertions(+), 272 deletions(-) create mode 100644 backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py diff --git a/backend/alembic/versions/0061_model_usage.py b/backend/alembic/versions/0061_model_usage.py index 9670c3cd8..184c6f771 100644 --- a/backend/alembic/versions/0061_model_usage.py +++ b/backend/alembic/versions/0061_model_usage.py @@ -322,7 +322,6 @@ def _upgrade_invocation_grants() -> None: request_sha256 varchar(71) NOT NULL, bound_at timestamptz DEFAULT clock_timestamp() NOT NULL, CONSTRAINT pk_checker_model_invocation_grant_calls PRIMARY KEY(grant_id,call_index), - CONSTRAINT uq_checker_model_invocation_grant_calls_attempt UNIQUE(attempt_id), CONSTRAINT ck_checker_model_invocation_grant_calls_facts CHECK ( call_index BETWEEN 0 AND 99 AND request_sha256 ~ '^sha256:[0-9a-f]{64}$'), CONSTRAINT fk_checker_model_invocation_grant_calls_grant FOREIGN KEY(grant_id) @@ -1951,10 +1950,21 @@ def downgrade() -> None: DECLARE dispatch_authority public.audit_events%ROWTYPE; DECLARE observation_authority public.audit_events%ROWTYPE; DECLARE terminal_authority public.audit_events%ROWTYPE; +DECLARE terminal_actor public.actor_profiles%ROWTYPE; DECLARE prepare_action text; DECLARE dispatch_action text; DECLARE observation_action text; DECLARE terminal_action text; +DECLARE terminal_service text; +DECLARE terminal_resource_type text; +DECLARE terminal_slot jsonb; +DECLARE terminal_effect jsonb; +DECLARE terminal_evidence jsonb; +DECLARE terminal_usage jsonb; +DECLARE terminal_digest varchar; +DECLARE terminal_facts jsonb; +DECLARE terminal_resource_facts jsonb; +DECLARE terminal_resource_digest varchar; BEGIN IF TG_OP='DELETE' THEN RAISE EXCEPTION 'checker model call attempts cannot be deleted' USING ERRCODE='55000'; @@ -2033,6 +2043,88 @@ def downgrade() -> None: profile.cached_input_rate::numeric + NEW.output_tokens::numeric * profile.output_rate::numeric; END IF; + IF NEW.state IN ('completed','known_failed','known_no_effect') THEN + terminal_evidence:=CASE WHEN NEW.state IN ('completed','known_failed') THEN + pg_catalog.jsonb_build_object( + 'binding_id',NEW.response_binding_id,'content_id',NEW.response_content_id, + 'put_attempt_id',NEW.response_put_attempt_id, + 'verification_receipt_id',NEW.response_verification_receipt_id, + 'content_sha256',NEW.response_content_sha256,'byte_count',NEW.response_byte_count, + 'media_type','application/json') ELSE NULL END; + terminal_usage:=CASE WHEN NEW.state IN ('completed','known_failed') THEN + pg_catalog.jsonb_build_object( + 'input_tokens',NEW.input_tokens,'cached_input_tokens',NEW.cached_input_tokens, + 'output_tokens',NEW.output_tokens,'reasoning_tokens',NEW.reasoning_tokens) + ELSE NULL END; + IF NEW.checker_slot_id IS NOT NULL THEN + terminal_service:='workstream.checker.post_submit'; + terminal_resource_type:='checker_model_call'; + terminal_slot:=pg_catalog.jsonb_build_object( + 'project_id',slot.project_id,'assignment_id',slot.assignment_id, + 'submission_id',slot.submission_id,'submission_version',slot.submission_version, + 'requirement_id',slot.requirement_id, + 'registry_entry_digest',slot.registry_entry_digest, + 'configuration_sha256',slot.configuration_sha256, + 'profile_id',slot.profile_id,'profile_version',slot.profile_version, + 'profile_digest',slot.profile_digest,'slot_index',slot.slot_index); + terminal_effect:=pg_catalog.jsonb_build_object( + 'project_id',slot.project_id,'task_id',slot.task_id,'slot_id',slot.id, + 'slot_key',terminal_slot,'attempt_id',NEW.id, + 'internal_call_request_id',NEW.internal_call_request_id); + IF NEW.state IN ('completed','known_failed') THEN + terminal_digest:=public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'status',NEW.state,'provider_response_id',NEW.provider_response_id, + 'provider_model_snapshot',profile.model_snapshot, + 'response_evidence',terminal_evidence,'usage',terminal_usage)); + END IF; + ELSE + terminal_service:='workstream.project.setup'; + terminal_resource_type:='setup_model_usage'; + terminal_slot:=pg_catalog.jsonb_build_object( + 'compilation_attempt_id',setup_slot.compilation_attempt_id, + 'project_id',setup_slot.project_id,'guide_id',setup_slot.guide_id, + 'source_snapshot_id',setup_slot.source_snapshot_id, + 'setup_run_id',setup_slot.setup_run_id, + 'setup_generation',setup_slot.setup_generation, + 'runtime_configuration_hash',setup_slot.runtime_configuration_hash, + 'profile_id',setup_slot.profile_id,'profile_version',setup_slot.profile_version, + 'profile_digest',setup_slot.profile_digest,'phase',setup_slot.phase, + 'call_index',setup_slot.call_index); + terminal_effect:=pg_catalog.jsonb_build_object( + 'project_id',setup_slot.project_id,'slot_id',setup_slot.id, + 'slot_key',terminal_slot,'effect_id',NEW.id, + 'internal_call_request_id',NEW.internal_call_request_id); + IF NEW.state IN ('completed','known_failed') THEN + terminal_digest:=public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'status','completed','provider_response_id',NEW.provider_response_id, + 'provider_model_snapshot',profile.model_snapshot, + 'response_evidence',terminal_evidence,'usage',terminal_usage, + 'usable',NEW.state='completed')); + END IF; + END IF; + IF NEW.state='known_no_effect' THEN + terminal_digest:=public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'state','known_no_effect','proof_id',NEW.no_effect_proof_id, + 'proof_digest',NEW.no_effect_proof_digest, + 'observed_at', + to_char(NEW.no_effect_observed_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS') || + CASE WHEN rtrim(to_char(NEW.no_effect_observed_at AT TIME ZONE 'UTC','US'),'0')='' + THEN '' ELSE '.' || + rtrim(to_char(NEW.no_effect_observed_at AT TIME ZONE 'UTC','US'),'0') END || '+00:00')); + END IF; + terminal_facts:=pg_catalog.jsonb_build_object( + 'effect',terminal_effect,'response_evidence',terminal_evidence, + 'terminal_digest',terminal_digest); + terminal_resource_facts:=CASE WHEN terminal_evidence IS NULL THEN + terminal_facts-'response_evidence' ELSE terminal_facts END; + terminal_resource_digest:=public.model_usage_json_digest( + pg_catalog.jsonb_build_object('resource_context',pg_catalog.jsonb_build_object( + 'resource_type',terminal_resource_type,'resource_id',NEW.id, + 'scope_project_id',coalesce(slot.project_id,setup_slot.project_id), + 'action_id',terminal_action,'facts',terminal_resource_facts, + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action',terminal_action,'facts',terminal_facts))))); + END IF; IF TG_OP='INSERT' THEN IF NEW.attempt_number=1 THEN IF NEW.parent_attempt_id IS NOT NULL THEN @@ -2225,15 +2317,21 @@ def downgrade() -> None: ELSIF NEW.state='completed' THEN SELECT * INTO terminal_authority FROM public.audit_events WHERE id=NEW.terminal_authorization_evidence_id FOR SHARE; - IF terminal_authority.id IS NULL OR + SELECT * INTO terminal_actor FROM public.actor_profiles + WHERE id=terminal_authority.actor_id::uuid FOR SHARE; + IF terminal_authority.id IS NULL OR terminal_actor.id IS NULL OR terminal_authority.event_domain IS DISTINCT FROM 'authority' OR terminal_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR terminal_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR terminal_authority.action_id IS DISTINCT FROM terminal_action OR terminal_authority.permission_id IS DISTINCT FROM terminal_action OR terminal_authority.denial_code IS NOT NULL OR - coalesce(terminal_authority.after_facts->>'resource_context_digest','') - !~ '^sha256:[0-9a-f]{64}$' OR + terminal_authority.after_facts::jsonb IS DISTINCT FROM + pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest',terminal_resource_digest) OR + terminal_actor.actor_kind IS DISTINCT FROM 'service' OR + terminal_actor.status IS DISTINCT FROM 'active' OR + terminal_actor.service_identity IS DISTINCT FROM terminal_service OR NEW.provider_response_id IS NULL OR NOT response_complete OR NOT usage_complete OR NEW.settled_nanodollars IS NULL OR NEW.settled_nanodollars::numeric<>exact_cost OR NEW.terminal_authorization_evidence_id IS NULL OR NEW.terminal_at IS NULL OR @@ -2245,35 +2343,48 @@ def downgrade() -> None: ELSIF NEW.state='known_failed' THEN SELECT * INTO terminal_authority FROM public.audit_events WHERE id=NEW.terminal_authorization_evidence_id FOR SHARE; - IF terminal_authority.id IS NULL OR + SELECT * INTO terminal_actor FROM public.actor_profiles + WHERE id=terminal_authority.actor_id::uuid FOR SHARE; + IF terminal_authority.id IS NULL OR terminal_actor.id IS NULL OR terminal_authority.event_domain IS DISTINCT FROM 'authority' OR terminal_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR terminal_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR terminal_authority.action_id IS DISTINCT FROM terminal_action OR terminal_authority.permission_id IS DISTINCT FROM terminal_action OR terminal_authority.denial_code IS NOT NULL OR - coalesce(terminal_authority.after_facts->>'resource_context_digest','') - !~ '^sha256:[0-9a-f]{64}$' OR + terminal_authority.after_facts::jsonb IS DISTINCT FROM + pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest',terminal_resource_digest) OR + terminal_actor.actor_kind IS DISTINCT FROM 'service' OR + terminal_actor.status IS DISTINCT FROM 'active' OR + terminal_actor.service_identity IS DISTINCT FROM terminal_service OR NEW.provider_response_id IS NULL OR NOT response_complete OR NEW.terminal_authorization_evidence_id IS NULL OR NEW.terminal_at IS NULL OR NEW.no_effect_proof_id IS NOT NULL OR NOT usage_complete OR NEW.settled_nanodollars IS NULL OR NEW.settled_nanodollars::numeric<>exact_cost OR - NEW.settlement_authorization_evidence_id IS NULL OR NEW.settled_at IS NULL THEN + NEW.settlement_authorization_evidence_id IS DISTINCT FROM + NEW.terminal_authorization_evidence_id OR NEW.settled_at IS NULL THEN RAISE EXCEPTION 'invalid known-failed checker model call' USING ERRCODE='23514'; END IF; ELSIF NEW.state='known_no_effect' THEN SELECT * INTO terminal_authority FROM public.audit_events WHERE id=NEW.terminal_authorization_evidence_id FOR SHARE; - IF terminal_authority.id IS NULL OR + SELECT * INTO terminal_actor FROM public.actor_profiles + WHERE id=terminal_authority.actor_id::uuid FOR SHARE; + IF terminal_authority.id IS NULL OR terminal_actor.id IS NULL OR terminal_authority.event_domain IS DISTINCT FROM 'authority' OR terminal_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR terminal_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR terminal_authority.action_id IS DISTINCT FROM terminal_action OR terminal_authority.permission_id IS DISTINCT FROM terminal_action OR terminal_authority.denial_code IS NOT NULL OR - coalesce(terminal_authority.after_facts->>'resource_context_digest','') - !~ '^sha256:[0-9a-f]{64}$' OR + terminal_authority.after_facts::jsonb IS DISTINCT FROM + pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest',terminal_resource_digest) OR + terminal_actor.actor_kind IS DISTINCT FROM 'service' OR + terminal_actor.status IS DISTINCT FROM 'active' OR + terminal_actor.service_identity IS DISTINCT FROM terminal_service OR NEW.provider_response_id IS NOT NULL OR NEW.response_binding_id IS NOT NULL OR NEW.response_content_id IS NOT NULL OR NEW.response_put_attempt_id IS NOT NULL OR NEW.response_verification_receipt_id IS NOT NULL OR diff --git a/backend/app/modules/model_usage/api.py b/backend/app/modules/model_usage/api.py index 211137d74..ec55b85a9 100644 --- a/backend/app/modules/model_usage/api.py +++ b/backend/app/modules/model_usage/api.py @@ -4,7 +4,7 @@ from collections.abc import AsyncIterable from dataclasses import dataclass -from datetime import datetime +from datetime import UTC, datetime from typing import Annotated, Literal, Protocol, Self from uuid import RFC_4122, UUID @@ -977,6 +977,15 @@ def exact_owner(self) -> Self: return self +def model_usage_instant(value: datetime) -> str: + """Match PostgreSQL's canonical UTC timestamptz JSON spelling exactly.""" + utc = value.astimezone(UTC) + rendered = utc.strftime("%Y-%m-%dT%H:%M:%S") + if utc.microsecond: + rendered += "." + f"{utc.microsecond:06d}".rstrip("0") + return rendered + "+00:00" + + def model_retry_terminal_digest( terminal: KnownFailedModelCall | KnownNoEffectProof, ) -> str: @@ -986,7 +995,7 @@ def model_retry_terminal_digest( "state": "known_no_effect", "proof_id": str(terminal.proof_id), "proof_digest": terminal.proof_digest, - "observed_at": terminal.observed_at.isoformat(), + "observed_at": model_usage_instant(terminal.observed_at), } else: facts = { @@ -1275,5 +1284,6 @@ def model_action_digest(action: str, facts: object) -> str: "SetupModelUsageSlotKey", "model_action_digest", "model_retry_terminal_digest", + "model_usage_instant", "model_usage_cost", ) diff --git a/backend/app/modules/model_usage/models.py b/backend/app/modules/model_usage/models.py index 89f38a07c..88c5f64cf 100644 --- a/backend/app/modules/model_usage/models.py +++ b/backend/app/modules/model_usage/models.py @@ -778,7 +778,6 @@ class CheckerModelInvocationGrantCall(Base): name="facts", ), UniqueConstraint("grant_id", "call_index", name="uq_checker_model_grant_call_index"), - UniqueConstraint("attempt_id", name="uq_checker_model_grant_call_attempt"), ) grant_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), diff --git a/backend/app/modules/model_usage/owner_ports.py b/backend/app/modules/model_usage/owner_ports.py index 879559d05..e091d53b0 100644 --- a/backend/app/modules/model_usage/owner_ports.py +++ b/backend/app/modules/model_usage/owner_ports.py @@ -96,6 +96,11 @@ async def recovery_owner( CheckerModelInvocationGrantCall.grant_id == CheckerModelInvocationGrant.id, ) .where(CheckerModelInvocationGrantCall.attempt_id == str(parent_attempt_id)) + .order_by( + CheckerModelInvocationGrant.issued_at.desc(), + CheckerModelInvocationGrant.id, + ) + .limit(1) ) if ( slot_row is None @@ -103,7 +108,7 @@ async def recovery_owner( or parent.checker_slot_id != slot_row.id or grant is None or grant.project_id != str(slot.project_id) - or ModelPaidSlotKey.model_validate(grant.paid_slot).envelope_digest + or ModelPaidSlotKey.model_validate_json(json.dumps(grant.paid_slot)).envelope_digest != slot.envelope_digest ): raise ModelCallUnavailable("model_recovery_source_unavailable") @@ -135,6 +140,7 @@ async def recovery_candidates( CheckerModelUsageSlot, CheckerModelInvocationGrant.registry_entry_id, ) + .select_from(CheckerModelInvocationGrant) .join( CheckerModelInvocationGrantCall, CheckerModelInvocationGrantCall.grant_id == CheckerModelInvocationGrant.id, diff --git a/backend/app/modules/model_usage/service.py b/backend/app/modules/model_usage/service.py index 42d342d35..86e5757cc 100644 --- a/backend/app/modules/model_usage/service.py +++ b/backend/app/modules/model_usage/service.py @@ -37,6 +37,7 @@ ProjectModelProfile, model_action_digest, model_retry_terminal_digest, + model_usage_instant, model_usage_cost, ) from app.modules.model_usage.models import ( @@ -208,7 +209,7 @@ def _stored_retry_terminal_digest(row: ModelUsageEffect) -> str | None: "state": row.state, "proof_id": row.no_effect_proof_id, "proof_digest": row.no_effect_proof_digest, - "observed_at": row.no_effect_observed_at.isoformat(), + "observed_at": model_usage_instant(row.no_effect_observed_at), } ) if row.state == "known_failed": diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index 5131ccf22..eeb1de856 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -494,6 +494,7 @@ class TestLane: "tests/checkers_model_calls/test_model_call_transactions_postgresql.py", "tests/checkers_model_calls/test_model_invocation_grants_postgresql.py", "tests/checkers_model_calls/test_model_shared_setup_postgresql.py", + "tests/checkers_model_calls/test_model_terminal_authority_postgresql.py", "tests/checkers_model_calls/test_model_usage_inspection.py", "tests/checkers_model_calls/test_model_runtime.py", "tests/checkers_model_calls/test_proxy_orchestration.py", diff --git a/backend/tests/checker_output_admission_helpers.py b/backend/tests/checker_output_admission_helpers.py index 45a8fc5e2..2c88fae67 100644 --- a/backend/tests/checker_output_admission_helpers.py +++ b/backend/tests/checker_output_admission_helpers.py @@ -145,7 +145,16 @@ async def make_checker_output_reservation( ) profile_id = new_record_id() actor_id = await session.scalar(select(ActorProfile.id).order_by(ActorProfile.id).limit(1)) - audit_event_id = await session.scalar(select(AuditEvent.id).order_by(AuditEvent.id).limit(1)) + audit_event_id = await session.scalar( + select(AuditEvent.id) + .where( + ~select(ModelUsageProfile.id) + .where(ModelUsageProfile.authorization_evidence_id == AuditEvent.id) + .exists() + ) + .order_by(AuditEvent.id) + .limit(1) + ) assert actor_id is not None and audit_event_id is not None per_call_bytes = max(maximum_bytes, 1024) profile_digest = canonical_json_hash( diff --git a/backend/tests/checkers_model_calls/model_usage_pg_support.py b/backend/tests/checkers_model_calls/model_usage_pg_support.py index 31340e585..e5a430652 100644 --- a/backend/tests/checkers_model_calls/model_usage_pg_support.py +++ b/backend/tests/checkers_model_calls/model_usage_pg_support.py @@ -63,7 +63,7 @@ def isolated(statement: str) -> str: "create table task_assignments(id uuid primary key, task_id uuid not null, project_id uuid not null)", "create table submissions(id uuid primary key, task_id uuid not null, version integer not null, unique(id,task_id,version))", "create table external_checker_registry_entries(id uuid primary key,entry_digest text not null,phase text not null)", - "create table checker_runs(id uuid primary key,task_id uuid,submission_id uuid,submission_version integer,project_id uuid,evaluation_request_id uuid,phase text,evaluation_generation integer,request_json text,worker_lease_id uuid,worker_lease_generation integer,worker_lease_expires_at timestamptz,status text)", + "create table checker_runs(id uuid primary key,task_id uuid,submission_id uuid,submission_version integer,project_id uuid,evaluation_request_id uuid,phase text,evaluation_generation integer,request_json text,worker_lease_id uuid,worker_lease_generation integer,worker_lease_expires_at timestamptz,status text,selected_post_submit_policy_sha256 text,selected_post_submit_policy_body jsonb,locked_post_submit_checker_policy_hash text)", "create table actor_profiles(id uuid primary key,actor_kind text default 'human',status text default 'active',service_identity text)", "create table audit_events(id uuid primary key, event_domain text, event_type text, actor_ref_kind text, actor_id text, action_id text, permission_id text, denial_code text, after_facts json)", "create table artifact_bindings(id uuid primary key)", diff --git a/backend/tests/checkers_model_calls/test_composed_execution.py b/backend/tests/checkers_model_calls/test_composed_execution.py index c7ddef26c..7f89296a9 100644 --- a/backend/tests/checkers_model_calls/test_composed_execution.py +++ b/backend/tests/checkers_model_calls/test_composed_execution.py @@ -23,6 +23,8 @@ CheckerModelCreateResult, CheckerModelObservation, CheckerModelProviderStatus, + CheckerModelRecoveryOutcome, + CheckerModelRecoveryResult, CheckerModelResponseReference, CheckerModelUsage, ) @@ -217,8 +219,22 @@ async def create(self, request): ), ) - async def recover(self, _): - raise AssertionError("completed call must use its retained ART response") + async def recover(self, reference): + # A concurrent callback can attach after the first dispatch has + # persisted its provider identity but before response ART is + # terminal. It may retrieve that exact effect, never create it + # again; completed replays still read retained ART directly. + return CheckerModelRecoveryResult( + CheckerModelRecoveryOutcome.OBSERVED, + reference, + CheckerModelObservation( + reference, + profiles[0]["spec"]["model_snapshot"], + CheckerModelProviderStatus.COMPLETED, + '{"verdict":"passed"}', + CheckerModelUsage(20, 5, 10, 2, 30), + ), + ) @asynccontextmanager async def provider(_): diff --git a/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py index 90c7a1818..044467037 100644 --- a/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime, timedelta, timezone import asyncio import json from uuid import UUID, uuid4 @@ -15,11 +15,16 @@ from app.core.identifiers import new_record_id from app.modules.authorization.domain.model_usage import ( BUDGET_CONFIGURE, + COMPLETE, PROFILE_PROVISION, RECOVER, + SETUP_COMPLETE, + ModelCallCompleteAuthorityFacts, ModelCallRecoverAuthorityFacts, + SetupModelCompleteAuthorityFacts, checker_model_call_resource, project_model_admin_resource, + setup_model_usage_resource, ) from app.modules.authorization.domain.resource_digest import authorization_resource_digest from app.modules.model_usage.api import ( @@ -43,9 +48,9 @@ SetupModelOwnerProfile, model_action_digest, model_retry_terminal_digest, + model_usage_instant, ) from app.modules.model_usage.models import ModelUsageBudgetAccount -from app.modules.model_usage.profiles import ModelProfileReader from app.modules.model_usage.service import ModelCallService from app.modules.model_usage.setup import SetupModelUsageService from tests.checkers_model_calls.model_usage_pg_support import ( @@ -69,6 +74,7 @@ class BoundaryPorts: current_checks: int = 0 events: list[str] = field(default_factory=list) corrupt_admin_receipt: bool = False + terminal_receipt_change: str | None = None def _decision(self, action: str, facts: object) -> ModelActionEvidence: self.events.append(action) @@ -329,10 +335,51 @@ async def authorize_child(self, project_id, parent_attempt_id, terminal_digest): async def authorize_complete(self, effect, response_evidence, terminal_digest): evidence = await self._ports.authorize_complete(effect, response_evidence, terminal_digest) + actor_id = new_record_id() + service_identity = ( + "workstream.project.setup" + if self._ports.terminal_receipt_change == "actor" + else "workstream.checker.post_submit" + ) + await self._session.execute( + text( + "insert into actor_profiles(id,actor_kind,status,service_identity) " + "values (:id,'service','active',:identity)" + ), + {"id": actor_id, "identity": service_identity}, + ) + authority_effect = effect + if self._ports.terminal_receipt_change == "effect": + authority_effect = effect.model_copy(update={"attempt_id": new_record_id()}) + elif self._ports.terminal_receipt_change == "project": + foreign_project = new_record_id() + authority_effect = effect.model_copy( + update={ + "project_id": foreign_project, + "slot_key": effect.slot_key.model_copy(update={"project_id": foreign_project}), + } + ) + facts = ModelCallCompleteAuthorityFacts( + effect=authority_effect, + response_evidence=response_evidence, + terminal_digest=terminal_digest, + ) + action = ( + "checker.model_call.observe" + if self._ports.terminal_receipt_change == "action" + else "checker.model_call.complete" + ) + resource_digest = authorization_resource_digest( + checker_model_call_resource(COMPLETE, facts) + ) + if self._ports.terminal_receipt_change == "digest": + resource_digest = sha("foreign terminal resource") return await self._authority( evidence, - action="checker.model_call.complete", - permission="checker.model_call.complete", + action=action, + permission=action, + actor_id=actor_id, + resource_digest=resource_digest, ) async def authorize_setup_prepare(self, slot, request_sha256): @@ -365,10 +412,51 @@ async def authorize_setup_complete(self, effect, response_evidence, terminal_dig evidence = await self._ports.authorize_setup_complete( effect, response_evidence, terminal_digest ) + actor_id = new_record_id() + service_identity = ( + "workstream.checker.post_submit" + if self._ports.terminal_receipt_change == "actor" + else "workstream.project.setup" + ) + await self._session.execute( + text( + "insert into actor_profiles(id,actor_kind,status,service_identity) " + "values (:id,'service','active',:identity)" + ), + {"id": actor_id, "identity": service_identity}, + ) + authority_effect = effect + if self._ports.terminal_receipt_change == "effect": + authority_effect = effect.model_copy(update={"effect_id": new_record_id()}) + elif self._ports.terminal_receipt_change == "project": + foreign_project = new_record_id() + authority_effect = effect.model_copy( + update={ + "project_id": foreign_project, + "slot_key": effect.slot_key.model_copy(update={"project_id": foreign_project}), + } + ) + facts = SetupModelCompleteAuthorityFacts( + effect=authority_effect, + response_evidence=response_evidence, + terminal_digest=terminal_digest, + ) + action = ( + "project.guide_compilation.model.observe" + if self._ports.terminal_receipt_change == "action" + else "project.guide_compilation.model.complete" + ) + resource_digest = authorization_resource_digest( + setup_model_usage_resource(SETUP_COMPLETE, facts) + ) + if self._ports.terminal_receipt_change == "digest": + resource_digest = sha("foreign setup terminal resource") return await self._authority( evidence, - action="project.guide_compilation.model.complete", - permission="project.guide_compilation.model.complete", + action=action, + permission=action, + actor_id=actor_id, + resource_digest=resource_digest, ) async def require_setup_request(self, prepared, evidence): @@ -882,8 +970,18 @@ async def test_uncertain_holds_budget_and_only_proven_no_effect_allows_child( proof = KnownNoEffectProof( proof_id=new_record_id(), proof_digest=sha("provider-no-effect"), - observed_at=datetime.now(UTC), + observed_at=datetime( + 2026, + 10, + 24, + 5, + 30, + 0, + 123_000, + tzinfo=timezone(timedelta(hours=5, minutes=30)), + ), ) + assert model_usage_instant(proof.observed_at) == "2026-10-24T00:00:00.123+00:00" async with AsyncSession(model_database.engine, expire_on_commit=False) as session: async with session.begin(): owner = service(session, ports) @@ -1027,53 +1125,6 @@ async def test_budget_adjustment_preserves_reserved_and_settles_old_snapshot( await service(session, ports).provision_budget(shrinking_facts) -async def test_known_failed_settles_once_and_allows_authorized_child( - model_database: DatabaseFacts, -) -> None: - ports = BoundaryPorts() - reservation = 100 * 11 + 50 * 29 - profile = await provision(model_database, ports, limit=reservation * 2) - run, paid_slot, prepared, dispatched, _ = await prepare_and_reserve( - model_database, ports, profile, "model_known_failure" - ) - usage = ModelCallUsage( - input_tokens=22, - cached_input_tokens=2, - output_tokens=3, - reasoning_tokens=1, - ) - response_art = evidence("failed-response", sha("failed-response")) - async with AsyncSession(model_database.engine, expire_on_commit=False) as session: - async with session.begin(): - owner = service(session, ports) - failed = await owner.complete_known_failure( - dispatched.effect, - provider_response_id="resp_terminal_failure", - provider_model_snapshot=profile.spec.model_snapshot, - response_evidence=response_art, - usage=usage, - ) - replay = await owner.complete_known_failure( - dispatched.effect, - provider_response_id="resp_terminal_failure", - provider_model_snapshot=profile.spec.model_snapshot, - response_evidence=response_art, - usage=usage, - ) - assert replay.replayed - child = await owner.prepare_recovery_child( - paid_slot, - registry_entry_id=model_database.registry_entry_id, - parent_attempt_id=prepared.attempt_id, - terminal_digest=model_retry_terminal_digest(failed), - request_sha256=prepared.request_sha256, - ) - assert child.attempt_number == 2 - account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) - assert account.lifetime_reserved_nanodollars == 0 - assert account.lifetime_settled_nanodollars == failed.settled_nanodollars - - async def test_database_guards_reject_ledger_mutation_and_counter_substitution( model_database: DatabaseFacts, ) -> None: @@ -1133,29 +1184,3 @@ async def test_database_ancestry_prevents_receipt_and_artifact_deletion( with pytest.raises(DBAPIError): async with session.begin(): await session.execute(text(statement), {"id": identifier}) - - -async def test_profile_reader_requires_exact_project_and_selector( - model_database: DatabaseFacts, -) -> None: - ports = BoundaryPorts() - profile = await provision(model_database, ports, limit=10_000) - async with AsyncSession(model_database.engine) as session: - async with session.begin(): - reader = ModelProfileReader(session) - assert ( - await reader.read_exact( - profile.selector, - project_id=model_database.project_id, - ) - == profile - ) - with pytest.raises(ModelCallUnavailable, match="model_profile_unavailable"): - await reader.read_exact(profile.selector, project_id=new_record_id()) - with pytest.raises(ModelCallUnavailable, match="model_profile_unavailable"): - await reader.read_exact( - profile.selector.model_copy( - update={"profile_digest": sha("substituted-profile")} - ), - project_id=model_database.project_id, - ) diff --git a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py index 8e5988a56..dd48ce1d3 100644 --- a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py @@ -133,19 +133,6 @@ async def grant_database(ledger_database_fixture: DatabaseFacts) -> GrantDatabas migration["_upgrade_invocation_grants"].__globals__["op"] = capture migration["_upgrade_invocation_grants"]() async with ledger_database.engine.begin() as connection: - await connection.execute( - text( - "alter table checker_runs add column project_id uuid, add column task_id uuid," - "add column submission_id uuid, add column submission_version integer," - "add column evaluation_request_id uuid, add column evaluation_generation integer," - "add column request_json text, add column worker_lease_id uuid," - "add column worker_lease_generation integer," - "add column worker_lease_expires_at timestamptz, add column status text," - "add column phase text, add column selected_post_submit_policy_sha256 text," - "add column selected_post_submit_policy_body jsonb," - "add column locked_post_submit_checker_policy_hash text" - ) - ) for statement in capture.statements: await connection.execute(text(_isolated(statement, schema))) @@ -338,6 +325,100 @@ async def test_grant_round_trip_binds_exact_calls_and_becomes_immutable( await session.execute(text(mutation)) +async def test_renewed_and_successor_grants_reattach_the_same_paid_effect( + grant_database: GrantDatabase, +) -> None: + """Lease renewal and an authorized retry run reuse one stable paid effect.""" + async with AsyncSession(grant_database.facts.engine, expire_on_commit=False) as session: + async with session.begin(): + boundaries = GrantBoundaries(session) + first = await _issue(grant_database, boundaries=boundaries, session=session) + owner = ModelInvocationGrants(session, authority=boundaries, current=boundaries) + first_facts = await owner.resolve(first.grant_id, first.binding_digest) + await owner.consume_prepared( + first_facts, + call_index=0, + request_sha256=grant_database.prepared_request_sha256, + prepared_attempt_id=grant_database.prepared_attempt_id, + ) + renewed = await _issue(grant_database, boundaries=boundaries, session=session) + renewed_facts = await owner.resolve(renewed.grant_id, renewed.binding_digest) + await owner.consume_prepared( + renewed_facts, + call_index=0, + request_sha256=grant_database.prepared_request_sha256, + prepared_attempt_id=grant_database.prepared_attempt_id, + ) + assert ( + await session.scalar( + text( + "select count(*) from checker_model_invocation_grant_calls " + "where attempt_id=:attempt" + ), + {"attempt": grant_database.prepared_attempt_id}, + ) + == 2 + ) + + successor = grant_database.execution.model_copy( + update={ + "checker_run_id": new_record_id(), + "evaluation_request_id": new_record_id(), + "evaluation_generation": grant_database.execution.evaluation_generation + 1, + "worker_lease_id": new_record_id(), + } + ) + await session.execute( + text( + "insert into checker_runs select :run,task_id,submission_id," + "submission_version,project_id,:request,phase,:generation," + "request_json,:lease,worker_lease_generation,worker_lease_expires_at," + "status,selected_post_submit_policy_sha256," + "selected_post_submit_policy_body,locked_post_submit_checker_policy_hash " + "from checker_runs where id=:source" + ), + { + "run": successor.checker_run_id, + "request": successor.evaluation_request_id, + "generation": successor.evaluation_generation, + "lease": successor.worker_lease_id, + "source": grant_database.execution.checker_run_id, + }, + ) + successor_database = GrantDatabase( + grant_database.facts, + successor, + grant_database.slot, + grant_database.prepared_attempt_id, + grant_database.prepared_request_sha256, + ) + successor_grant = await _issue( + successor_database, + boundaries=boundaries, + session=session, + ) + successor_facts = await owner.resolve( + successor_grant.grant_id, + successor_grant.binding_digest, + ) + await owner.consume_prepared( + successor_facts, + call_index=0, + request_sha256=grant_database.prepared_request_sha256, + prepared_attempt_id=grant_database.prepared_attempt_id, + ) + assert ( + await session.scalar( + text( + "select count(*) from checker_model_invocation_grant_calls " + "where attempt_id=:attempt" + ), + {"attempt": grant_database.prepared_attempt_id}, + ) + == 3 + ) + + async def test_grant_rejects_reused_or_wrong_authority_and_stale_db_clock( grant_database: GrantDatabase, ) -> None: diff --git a/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py index 2e08fe9b7..054debfb0 100644 --- a/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py @@ -18,6 +18,7 @@ ModelGenerationSettings, ModelProfileProvisioningFacts, ModelProfileReader, + ModelCallUnavailable, ModelUsageBudgetAccount, asyncio, canonical_json_hash, @@ -39,6 +40,32 @@ model_database = _model_database +async def test_profile_reader_requires_exact_project_and_selector( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + profile = await provision(model_database, ports, limit=10_000) + async with AsyncSession(model_database.engine) as session: + async with session.begin(): + reader = ModelProfileReader(session) + assert ( + await reader.read_exact( + profile.selector, + project_id=model_database.project_id, + ) + == profile + ) + with pytest.raises(ModelCallUnavailable, match="model_profile_unavailable"): + await reader.read_exact(profile.selector, project_id=new_record_id()) + with pytest.raises(ModelCallUnavailable, match="model_profile_unavailable"): + await reader.read_exact( + profile.selector.model_copy( + update={"profile_digest": sha("substituted-profile")} + ), + project_id=model_database.project_id, + ) + + async def test_provisioning_replay_retains_original_receipts_with_fresh_authority( model_database: DatabaseFacts, ) -> None: diff --git a/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py b/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py new file mode 100644 index 000000000..b462a687d --- /dev/null +++ b/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py @@ -0,0 +1,221 @@ +"""Exact terminal AUTH and settlement custody for shared model effects.""" + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash +from app.modules.model_usage.api import ModelCallUsage, model_retry_terminal_digest +from app.modules.model_usage.models import ModelUsageBudgetAccount +from tests.checkers_model_calls.model_usage_pg_support import ( + DatabaseFacts, + model_database as _model_database, +) +from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( + BoundaryPorts, + DatabaseBoundaryPorts, + evidence, + prepare_and_reserve, + provision, + service, + setup_service, + setup_slot_key, + sha, +) + +model_database = _model_database + + +async def test_known_failed_settles_once_and_allows_authorized_child( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation * 2) + _, paid_slot, prepared, dispatched, _ = await prepare_and_reserve( + model_database, ports, profile, "model_known_failure" + ) + usage = ModelCallUsage( + input_tokens=22, + cached_input_tokens=2, + output_tokens=3, + reasoning_tokens=1, + ) + response_art = evidence("failed-response", sha("failed-response")) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + failed = await owner.complete_known_failure( + dispatched.effect, + provider_response_id="resp_terminal_failure", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response_art, + usage=usage, + ) + replay = await owner.complete_known_failure( + dispatched.effect, + provider_response_id="resp_terminal_failure", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response_art, + usage=usage, + ) + assert replay.replayed + child = await owner.prepare_recovery_child( + paid_slot, + registry_entry_id=model_database.registry_entry_id, + parent_attempt_id=prepared.attempt_id, + terminal_digest=model_retry_terminal_digest(failed), + request_sha256=prepared.request_sha256, + ) + assert child.attempt_number == 2 + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == failed.settled_nanodollars + + +@pytest.mark.parametrize("substitution", ("project", "effect", "action", "actor", "digest")) +async def test_known_failed_rejects_substituted_terminal_authority( + model_database: DatabaseFacts, + substitution: str, +) -> None: + """A shape-valid receipt cannot terminalize any other effect or service scope.""" + ports = BoundaryPorts(terminal_receipt_change=substitution) + profile = await provision(model_database, ports, limit=10_000) + _, _, _, dispatched, _ = await prepare_and_reserve( + model_database, + ports, + profile, + "model_terminal_authority_" + substitution, + ) + with pytest.raises(DBAPIError, match="invalid known-failed checker model call"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await service(session, ports).complete_known_failure( + dispatched.effect, + provider_response_id="resp_terminal_authority_" + substitution, + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=evidence( + "terminal-authority-" + substitution, + sha("terminal-authority-" + substitution), + ), + usage=ModelCallUsage( + input_tokens=22, + cached_input_tokens=2, + output_tokens=3, + reasoning_tokens=1, + ), + ) + + +@pytest.mark.parametrize("substitution", ("project", "effect", "action", "actor", "digest")) +async def test_setup_known_failure_rejects_substituted_terminal_authority( + model_database: DatabaseFacts, + substitution: str, +) -> None: + """Setup terminal custody rejects every crossed project, effect or service receipt.""" + ports = BoundaryPorts(terminal_receipt_change=substitution) + profile = await provision(model_database, ports, limit=10_000) + key = await setup_slot_key(model_database, profile) + request_sha = sha("setup-terminal-authority-" + substitution) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = setup_service(session, ports) + prepared = await owner.prepare(key, request_sha) + dispatched = await owner.reserve_dispatch( + prepared, + evidence("setup-terminal-request-" + substitution, request_sha), + ) + with pytest.raises(DBAPIError, match="invalid known-failed checker model call"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await setup_service(session, ports).complete( + dispatched.effect, + provider_response_id="resp_setup_terminal_" + substitution, + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=evidence( + "setup-terminal-response-" + substitution, + sha("setup-terminal-response-" + substitution), + ), + usage=ModelCallUsage( + input_tokens=22, + cached_input_tokens=2, + output_tokens=3, + reasoning_tokens=1, + ), + usable=False, + ) + + +async def test_known_failed_rejects_distinct_settlement_authority( + model_database: DatabaseFacts, +) -> None: + """Settlement must use the exact terminal decision, not a second allowed receipt.""" + ports = BoundaryPorts() + profile = await provision(model_database, ports, limit=10_000) + _, _, _, dispatched, _ = await prepare_and_reserve( + model_database, + ports, + profile, + "model_terminal_settlement_authority", + ) + response = evidence("settlement-authority", sha("settlement-authority")) + usage = ModelCallUsage( + input_tokens=22, + cached_input_tokens=2, + output_tokens=3, + reasoning_tokens=1, + ) + terminal_digest = canonical_json_hash( + { + "status": "known_failed", + "provider_response_id": "resp_settlement_authority", + "provider_model_snapshot": profile.spec.model_snapshot, + "response_evidence": response.model_dump(mode="json"), + "usage": usage.model_dump(mode="json"), + } + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + boundaries = DatabaseBoundaryPorts(session, ports) + with pytest.raises(DBAPIError, match="invalid known-failed checker model call"): + async with session.begin(): + terminal = await boundaries.authorize_complete( + dispatched.effect, response, terminal_digest + ) + settlement = await boundaries.authorize_complete( + dispatched.effect, response, terminal_digest + ) + await boundaries.require_response(dispatched.effect.attempt_id, response) + await session.execute( + text( + "update model_usage_effects set state='known_failed'," + "provider_response_id='resp_settlement_authority'," + "response_binding_id=:binding,response_content_id=:content," + "response_put_attempt_id=:put," + "response_verification_receipt_id=:receipt," + "response_content_sha256=:sha,response_byte_count=:bytes," + "input_tokens=:input,cached_input_tokens=:cached," + "output_tokens=:output,reasoning_tokens=:reasoning," + "settled_nanodollars=:cost," + "terminal_authorization_evidence_id=:terminal," + "settlement_authorization_evidence_id=:settlement," + "terminal_at=clock_timestamp(),settled_at=clock_timestamp() " + "where id=:effect" + ), + { + "binding": response.binding_id, + "content": response.content_id, + "put": response.put_attempt_id, + "receipt": response.verification_receipt_id, + "sha": response.content_sha256, + "bytes": response.byte_count, + "input": usage.input_tokens, + "cached": usage.cached_input_tokens, + "output": usage.output_tokens, + "reasoning": usage.reasoning_tokens, + "cost": 311, + "terminal": terminal.evidence_id, + "settlement": settlement.evidence_id, + "effect": dispatched.effect.attempt_id, + }, + ) diff --git a/backend/tests/test_checker_output_storage.py b/backend/tests/test_checker_output_storage.py index 0f17241fd..2b3e8bbfb 100644 --- a/backend/tests/test_checker_output_storage.py +++ b/backend/tests/test_checker_output_storage.py @@ -55,11 +55,13 @@ async def _store_verified_output( reservation_ttl_seconds=360, total_deadline_seconds=300, ) - relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) + relationships = await seed_checker_output_relationships( + session, namespace, policy_bundle=policy_bundle + ) async with minted_source( source_path, payload, - media_type="text/plain", + media_type="application/json", limits=limits, ) as source: project_id, task_id, checker_run_id, admission = await _admit_checker_output( @@ -77,14 +79,16 @@ async def _store_verified_output( _AllowArtifactAuthority(), ) await orchestrator.ensure_storage_namespace() - assert await orchestrator.execute_committed_put( - attempt_id=admission.attempt_id, - source=source, - ) == "stored_pending_verification" + assert ( + await orchestrator.execute_committed_put( + attempt_id=admission.attempt_id, + source=source, + ) + == "stored_pending_verification" + ) job_id = await session.scalar( select(ArtifactVerificationJob.id).where( - ArtifactVerificationJob.originating_put_attempt_id - == str(admission.attempt_id) + ArtifactVerificationJob.originating_put_attempt_id == str(admission.attempt_id) ) ) assert job_id is not None @@ -98,6 +102,7 @@ async def _store_verified_output( ) attempt = await session.get(ArtifactPutAttempt, str(admission.attempt_id)) assert attempt is not None and receipt_id is not None + assert attempt.model_call_attempt_id is not None assert attempt.replica_id is not None logical_role = attempt.logical_role replica_id = attempt.replica_id @@ -112,6 +117,7 @@ async def _store_verified_output( project_id=project_id, task_id=task_id, checker_run_id=checker_run_id, + model_call_attempt_id=attempt.model_call_attempt_id, put_attempt_id=str(admission.attempt_id), verification_job_id=str(job_id), replica_id=str(replica_id), @@ -140,7 +146,7 @@ async def _verified_output(database_url: str, tmp_path: Path): store=store, policy_bundle=policy_bundle, source_path=tmp_path / "checker-output", - payload=b"exact checker output", + payload=b'{"result":"exact checker output"}', ) case.settings = settings case.store = store @@ -156,8 +162,8 @@ def _binding_values(case, **changes): "id": str(new_record_id()), "content_id": case.content_id, "project_id": case.project_id, - "resource_type": "checker_run", - "resource_id": case.checker_run_id, + "resource_type": "checker_model_call", + "resource_id": case.model_call_attempt_id, "logical_role": case.logical_role, "scope_version": 1, "actor_id": ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value, @@ -170,6 +176,16 @@ def _binding_values(case, **changes): return values +def _historical_checker_run_binding_values(case, **changes): + """Build the retained pre-model-call CHECKERS binding used by migration proofs.""" + return _binding_values( + case, + resource_type="checker_run", + resource_id=case.checker_run_id, + **changes, + ) + + _INSERT_BINDING = text( """insert into artifact_bindings( id,content_id,project_id,resource_type,resource_id,logical_role,scope_version, @@ -212,10 +228,7 @@ async def _sealed_chain(session, case) -> tuple[bool, bool, bool]: ("artifact_put_attempts", "put_attempt_id"), ): value = await session.scalar( - text( - f"select checker_output_custody_sealed from {table} " - "where id=:id" - ), + text(f"select checker_output_custody_sealed from {table} where id=:id"), {"id": getattr(case, attribute)}, ) values.append(bool(value)) @@ -225,10 +238,7 @@ async def _sealed_chain(session, case) -> tuple[bool, bool, bool]: async def _wait_for_lock(session, backend_pid: int) -> None: for _ in range(500): waiting = await session.scalar( - text( - "select exists(select 1 from pg_locks " - "where pid=:pid and not granted)" - ), + text("select exists(select 1 from pg_locks where pid=:pid and not granted)"), {"pid": backend_pid}, ) if waiting: @@ -245,9 +255,7 @@ async def _transaction(connection, isolation: str): def _ancestor_update(case, ancestor: str): table, attribute, assignment, _trigger, _error = _ANCESTOR_MUTATIONS[ancestor] - return text(f"update {table} set {assignment} where id=:id"), { - "id": getattr(case, attribute) - } + return text(f"update {table} set {assignment} where id=:id"), {"id": getattr(case, attribute)} def _invalid_ancestor_update(case, ancestor: str): @@ -263,9 +271,7 @@ def _invalid_ancestor_update(case, ancestor: str): ), "replica": "provider_object_ref=provider_object_ref || '-moved'", }[ancestor] - return text(f"update {table} set {assignment} where id=:id"), { - "id": getattr(case, attribute) - } + return text(f"update {table} set {assignment} where id=:id"), {"id": getattr(case, attribute)} async def _ancestor_without_seal(session, case, ancestor: str): @@ -331,13 +337,13 @@ async def _binding_first_race(case, ancestor: str, isolation: str) -> None: async with case.factory() as session: assert await _sealed_chain(session, case) == (True, True, True) assert await _ancestor_without_seal(session, case, ancestor) == before - assert await session.scalar( - text( - "select count(*) from artifact_bindings " - "where put_attempt_id=:put_attempt_id" - ), - {"put_attempt_id": case.put_attempt_id}, - ) == 1 + assert ( + await session.scalar( + text("select count(*) from artifact_bindings where put_attempt_id=:put_attempt_id"), + {"put_attempt_id": case.put_attempt_id}, + ) + == 1 + ) async def _update_first_race(case, ancestor: str, isolation: str) -> None: @@ -381,13 +387,13 @@ async def _update_first_race(case, ancestor: str, isolation: str) -> None: async with case.factory() as session: assert await _sealed_chain(session, case) == (False, False, False) assert await _ancestor_without_seal(session, case, ancestor) != before - assert await session.scalar( - text( - "select count(*) from artifact_bindings " - "where put_attempt_id=:put_attempt_id" - ), - {"put_attempt_id": case.put_attempt_id}, - ) == 0 + assert ( + await session.scalar( + text("select count(*) from artifact_bindings where put_attempt_id=:put_attempt_id"), + {"put_attempt_id": case.put_attempt_id}, + ) + == 0 + ) @pytest.mark.asyncio @@ -413,12 +419,15 @@ async def test_exact_verified_checker_output_binding_and_generic_binding_remain_ ), ) async with case.factory() as session: - assert await session.scalar( - text( - "select count(*) from artifact_bindings " - "where resource_type in ('checker_run','task')" + assert ( + await session.scalar( + text( + "select count(*) from artifact_bindings " + "where resource_type in ('checker_model_call','task')" + ) ) - ) == 2 + == 2 + ) @pytest.mark.asyncio @@ -436,8 +445,7 @@ async def test_checker_binding_rejects_null_verification_terminal_result( async with case.factory() as session, session.begin(): await session.execute( text( - "update artifact_verification_jobs " - "set terminal_result_code=null where id=:id" + "update artifact_verification_jobs set terminal_result_code=null where id=:id" ), {"id": case.verification_job_id}, ) @@ -450,13 +458,16 @@ async def test_checker_binding_rejects_null_verification_terminal_result( await session.execute(_INSERT_BINDING, _binding_values(case)) async with case.factory() as session: assert await _sealed_chain(session, case) == (False, False, False) - assert await session.scalar( - text( - "select count(*) from artifact_bindings " - "where put_attempt_id=:put_attempt_id" - ), - {"put_attempt_id": case.put_attempt_id}, - ) == 0 + assert ( + await session.scalar( + text( + "select count(*) from artifact_bindings " + "where put_attempt_id=:put_attempt_id" + ), + {"put_attempt_id": case.put_attempt_id}, + ) + == 0 + ) async with case.factory() as session: mutation = await session.begin() @@ -477,13 +488,16 @@ async def test_checker_binding_rejects_null_verification_terminal_result( async with case.factory() as session: assert await _sealed_chain(session, case) == (False, False, False) - assert await session.scalar( - text( - "select count(*) from artifact_bindings " - "where put_attempt_id=:put_attempt_id" - ), - {"put_attempt_id": case.put_attempt_id}, - ) == 0 + assert ( + await session.scalar( + text( + "select count(*) from artifact_bindings " + "where put_attempt_id=:put_attempt_id" + ), + {"put_attempt_id": case.put_attempt_id}, + ) + == 0 + ) @pytest.mark.asyncio @@ -499,7 +513,7 @@ async def test_checker_binding_rejects_mixed_or_foreign_ancestry( store=case.store, policy_bundle=case.policy_bundle, source_path=tmp_path / "other-checker-output", - payload=b"distinct independently verified checker output", + payload=b'{"result":"distinct independently verified checker output"}', ) assert case.put_attempt_id != other.put_attempt_id assert case.verification_receipt_id != other.verification_receipt_id @@ -510,13 +524,25 @@ async def test_checker_binding_rejects_mixed_or_foreign_ancestry( control = await session.begin_nested() await session.execute(_INSERT_BINDING, _binding_values(case)) await session.execute(_INSERT_BINDING, _binding_values(other)) - assert await session.scalar( - text("select count(*) from artifact_bindings where resource_type='checker_run'") - ) == 2 + assert ( + await session.scalar( + text( + "select count(*) from artifact_bindings " + "where resource_type='checker_model_call'" + ) + ) + == 2 + ) await control.rollback() - assert await session.scalar( - text("select count(*) from artifact_bindings where resource_type='checker_run'") - ) == 0 + assert ( + await session.scalar( + text( + "select count(*) from artifact_bindings " + "where resource_type='checker_model_call'" + ) + ) + == 0 + ) mixed_lineages = ( { @@ -539,9 +565,15 @@ async def test_checker_binding_rejects_mixed_or_foreign_ancestry( _binding_values(case, **mixed), ) async with case.factory() as session: - assert await session.scalar( - text("select count(*) from artifact_bindings where resource_type='checker_run'") - ) == 0 + assert ( + await session.scalar( + text( + "select count(*) from artifact_bindings " + "where resource_type='checker_model_call'" + ) + ) + == 0 + ) @pytest.mark.asyncio @@ -564,9 +596,7 @@ async def test_checker_attempt_static_custody_allows_only_execution_lifecycle( ): async with case.factory() as session, session.begin(): await session.execute( - text( - f"update artifact_put_attempts set {assignment} where id=:id" - ), + text(f"update artifact_put_attempts set {assignment} where id=:id"), {"id": case.put_attempt_id}, ) with pytest.raises( @@ -605,25 +635,18 @@ async def test_binding_seals_exact_ancestry_but_preserves_replica_health( with pytest.raises(DBAPIError, match="seal requires binding"): async with case.factory() as session, session.begin(): await session.execute( - text( - f"update {table} set checker_output_custody_sealed=true " - "where id=:id" - ), + text(f"update {table} set checker_output_custody_sealed=true where id=:id"), {"id": getattr(case, attribute)}, ) async with case.factory() as session, session.begin(): await session.execute( - text( - "update artifact_put_attempts set cas_version=cas_version+1 " - "where id=:id" - ), + text("update artifact_put_attempts set cas_version=cas_version+1 where id=:id"), {"id": case.put_attempt_id}, ) await session.execute( text( - "update artifact_verification_jobs set cas_version=cas_version+1 " - "where id=:id" + "update artifact_verification_jobs set cas_version=cas_version+1 where id=:id" ), {"id": case.verification_job_id}, ) @@ -683,10 +706,13 @@ async def test_binding_seals_exact_ancestry_but_preserves_replica_health( ) assert after != before await transaction.rollback() - assert await session.scalar( - text(f"select to_jsonb(value) from {table} value where id=:id"), - {"id": getattr(case, attribute)}, - ) == before + assert ( + await session.scalar( + text(f"select to_jsonb(value) from {table} value where id=:id"), + {"id": getattr(case, attribute)}, + ) + == before + ) await session.rollback() @@ -707,10 +733,13 @@ async def test_binding_rollback_removes_all_ancestry_seals( await transaction.rollback() async with case.factory() as session: assert await _sealed_chain(session, case) == (False, False, False) - assert await session.scalar( - text("select count(*) from artifact_bindings where id=:id"), - {"id": binding_id}, - ) == 0 + assert ( + await session.scalar( + text("select count(*) from artifact_bindings where id=:id"), + {"id": binding_id}, + ) + == 0 + ) @pytest.mark.parametrize("ancestor", tuple(_ANCESTOR_MUTATIONS)) @@ -732,7 +761,7 @@ async def test_binding_and_ancestor_updates_serialize_fail_closed( store=case.store, policy_bundle=case.policy_bundle, source_path=tmp_path / f"update-first-{ancestor}", - payload=f"update-first-{isolation}-{ancestor}".encode(), + payload=('{"result":"update-first-' + isolation + "-" + ancestor + '"}').encode(), ) other.engine = case.engine await _update_first_race(other, ancestor, isolation) @@ -760,24 +789,37 @@ async def test_artifact_binding_truncate_custody_blocks_direct_and_cascade_delet async with case.factory() as session, session.begin(): await session.execute(text(statement)) async with case.factory() as session: - assert await session.scalar( - text("select count(*) from artifact_bindings where id=:id"), - {"id": binding_id}, - ) == 1 + assert ( + await session.scalar( + text("select count(*) from artifact_bindings where id=:id"), + {"id": binding_id}, + ) + == 1 + ) async with case.factory() as session: transaction = await session.begin() try: # Isolate the binding trigger from the new inbound packet FK. This # rollback-only probe has no packets; production custody stays intact. - assert await session.scalar(text("select count(*) from review_packet_manifests")) == 0 - await session.execute(text( - "alter table review_packet_manifests drop constraint " - "fk_review_packet_manifests_submission_binding_id_artifa_4ee3" - )) + assert ( + await session.scalar(text("select count(*) from review_packet_manifests")) == 0 + ) + await session.execute( + text( + "alter table review_packet_manifests drop constraint " + "fk_review_packet_manifests_submission_binding_id_artifa_4ee3" + ) + ) for table, constraint in ( - ("submission_binding_receipts", "fk_submission_binding_receipts_binding_id_artifact_bindings"), - ("submission_dispatches", "fk_submission_dispatches_artifact_binding_id_artifact_bindings"), + ( + "submission_binding_receipts", + "fk_submission_binding_receipts_binding_id_artifact_bindings", + ), + ( + "submission_dispatches", + "fk_submission_dispatches_artifact_binding_id_artifact_bindings", + ), ): # Canonical creation already retained one receipt. The # FK removal and deliberate truncation roll back together. @@ -793,18 +835,24 @@ async def test_artifact_binding_truncate_custody_blocks_direct_and_cascade_delet ) ) await session.execute(text("truncate artifact_bindings")) - assert await session.scalar( - text("select count(*) from artifact_bindings where id=:id"), - {"id": binding_id}, - ) == 0 + assert ( + await session.scalar( + text("select count(*) from artifact_bindings where id=:id"), + {"id": binding_id}, + ) + == 0 + ) finally: await transaction.rollback() async with case.factory() as session: - assert await session.scalar( - text("select count(*) from artifact_bindings where id=:id"), - {"id": binding_id}, - ) == 1 + assert ( + await session.scalar( + text("select count(*) from artifact_bindings where id=:id"), + {"id": binding_id}, + ) + == 1 + ) for table in ("submission_binding_receipts", "submission_dispatches"): assert await session.scalar(text(f"select count(*) from {table}")) == 1 trigger_definition = await session.scalar( @@ -817,16 +865,26 @@ async def test_artifact_binding_truncate_custody_blocks_direct_and_cascade_delet assert trigger_definition is not None assert "BEFORE TRUNCATE" in trigger_definition assert "EXECUTE FUNCTION reject_artifact_fact_mutation()" in trigger_definition - assert await session.scalar(text( - "select tgenabled='O' from pg_trigger " - "where tgrelid='artifact_bindings'::regclass " - "and tgname='trg_artifact_bindings_no_truncate'" - )) is True - assert await session.scalar(text( - "select count(*) from pg_constraint " - "where conrelid='review_packet_manifests'::regclass " - "and conname='fk_review_packet_manifests_submission_binding_id_artifa_4ee3'" - )) == 1 + assert ( + await session.scalar( + text( + "select tgenabled='O' from pg_trigger " + "where tgrelid='artifact_bindings'::regclass " + "and tgname='trg_artifact_bindings_no_truncate'" + ) + ) + is True + ) + assert ( + await session.scalar( + text( + "select count(*) from pg_constraint " + "where conrelid='review_packet_manifests'::regclass " + "and conname='fk_review_packet_manifests_submission_binding_id_artifa_4ee3'" + ) + ) + == 1 + ) @pytest.mark.asyncio @@ -847,28 +905,35 @@ async def test_binding_guard_uses_only_artifact_ancestry_and_exact_owner_fks( assert "submissions" not in definition assert "workstream_tasks" not in definition constraints = dict( - (await connection.execute(text( - "select conname,pg_get_constraintdef(oid) from pg_constraint " - "where conrelid='artifact_put_attempts'::regclass and conname in " - "('fk_artifact_put_attempts_checker_run_ownership'," - "'fk_artifact_put_attempts_submission_version'," - "'fk_artifact_put_attempts_task_project')" - ))).all() + ( + await connection.execute( + text( + "select conname,pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='artifact_put_attempts'::regclass and conname in " + "('fk_artifact_put_attempts_checker_run_ownership'," + "'fk_artifact_put_attempts_submission_version'," + "'fk_artifact_put_attempts_task_project')" + ) + ) + ).all() ) assert set(constraints) == { "fk_artifact_put_attempts_checker_run_ownership", "fk_artifact_put_attempts_submission_version", "fk_artifact_put_attempts_task_project", } - assert "FOREIGN KEY (checker_run_id, task_id, submission_id)" in constraints[ - "fk_artifact_put_attempts_checker_run_ownership" - ] - assert "FOREIGN KEY (submission_id, task_id, submission_version)" in constraints[ - "fk_artifact_put_attempts_submission_version" - ] - assert "FOREIGN KEY (task_id, project_id)" in constraints[ - "fk_artifact_put_attempts_task_project" - ] + assert ( + "FOREIGN KEY (checker_run_id, task_id, submission_id)" + in constraints["fk_artifact_put_attempts_checker_run_ownership"] + ) + assert ( + "FOREIGN KEY (submission_id, task_id, submission_version)" + in constraints["fk_artifact_put_attempts_submission_version"] + ) + assert ( + "FOREIGN KEY (task_id, project_id)" + in constraints["fk_artifact_put_attempts_task_project"] + ) finally: await engine.dispose() @@ -944,26 +1009,41 @@ async def _restore_pre_0007_schema(connection) -> None: "drop column checker_output_custody_sealed" ) ) + await connection.execute( + text("alter table artifact_verification_jobs drop column checker_output_custody_sealed") + ) + await connection.execute( + text("alter table artifact_replicas drop column checker_output_custody_sealed") + ) + + +async def _arrange_historical_checker_run_output(connection, case) -> None: + """Convert one controlled current fixture into its retained pre-model lineage.""" + await connection.execute( + text("alter table artifact_put_attempts disable trigger checker_output_put_attempt_custody") + ) await connection.execute( text( - "alter table artifact_verification_jobs " - "drop column checker_output_custody_sealed" - ) + "update artifact_put_attempts set checker_run_id=:run, " + "model_call_attempt_id=null where id=:attempt" + ), + {"run": case.checker_run_id, "attempt": case.put_attempt_id}, + ) + await connection.execute( + text("alter table artifact_put_attempts enable trigger checker_output_put_attempt_custody") ) await connection.execute( text( - "alter table artifact_replicas " - "drop column checker_output_custody_sealed" - ) + "update artifact_operation_receipts set checker_run_id=:run, " + "model_call_attempt_id=null where put_attempt_id=:attempt" + ), + {"run": case.checker_run_id, "attempt": case.put_attempt_id}, ) def _run_0007_upgrade(connection) -> None: module = runpy.run_path( - str( - Path(__file__).resolve().parents[1] - / "alembic/versions/0007_checker_output_custody.py" - ) + str(Path(__file__).resolve().parents[1] / "alembic/versions/0007_checker_output_custody.py") ) with Operations.context(MigrationContext.configure(connection)): module["upgrade"]() @@ -984,6 +1064,7 @@ async def test_migration_refuses_unprovable_or_inconsistent_retained_checker_att ) async with case.factory() as session: connection = await session.connection() + await _arrange_historical_checker_run_output(connection, case) await _restore_pre_0007_schema(connection) before = await session.scalar( text("select to_jsonb(a) from artifact_put_attempts a where id=:id"), @@ -995,10 +1076,13 @@ async def test_migration_refuses_unprovable_or_inconsistent_retained_checker_att ): async with connection.begin_nested(): await connection.run_sync(_run_0007_upgrade) - assert await session.scalar( - text("select to_jsonb(a) from artifact_put_attempts a where id=:id"), - {"id": case.put_attempt_id}, - ) == before + assert ( + await session.scalar( + text("select to_jsonb(a) from artifact_put_attempts a where id=:id"), + {"id": case.put_attempt_id}, + ) + == before + ) await session.execute( text("update artifact_put_attempts set task_id=:task where id=:id"), @@ -1014,8 +1098,11 @@ async def test_migration_refuses_unprovable_or_inconsistent_retained_checker_att ): async with connection.begin_nested(): await connection.run_sync(_run_0007_upgrade) - assert await session.scalar( - text("select to_jsonb(a) from artifact_put_attempts a where id=:id"), - {"id": case.put_attempt_id}, - ) == mismatched + assert ( + await session.scalar( + text("select to_jsonb(a) from artifact_put_attempts a where id=:id"), + {"id": case.put_attempt_id}, + ) + == mismatched + ) await session.rollback() diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index 979b210d9..0ad3470b0 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -70,6 +70,7 @@ def test_checker_delivery_and_routing_preparation_partition_every_node_once() -> "tests/checkers_model_calls/test_model_call_transactions_postgresql.py", "tests/checkers_model_calls/test_model_invocation_grants_postgresql.py", "tests/checkers_model_calls/test_model_shared_setup_postgresql.py", + "tests/checkers_model_calls/test_model_terminal_authority_postgresql.py", "tests/checkers_model_calls/test_model_usage_inspection.py", "tests/checkers_model_calls/test_model_runtime.py", "tests/checkers_model_calls/test_proxy_orchestration.py", @@ -288,8 +289,8 @@ def test_semantic_family_membership_has_an_independent_fingerprint() -> None: "c8a0538c251f77fc1893ed8aa76feff8fa2134cf8e9b5a049f32fbabd5226277", ), "project": ( - 229, - "5b644de8f6d38d47c7bcd7f048a3497178597dc610f8da5bbe67fccd570a269a", + 230, + "ee63beaac967661fa841640a640bba08ff0b1b9177de73b8a5dffb83944caf03", ), "task": ( 95, @@ -361,9 +362,9 @@ def test_project_lane_ownership_has_an_independent_fingerprint() -> None: ) + "\n" ) - assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 229 + assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 230 assert hashlib.sha256(canonical.encode()).hexdigest() == ( - "972af04cc7a86c871d97fe4b059a870e0849d9586a67eb8b523c910968f88c8a" + "4663d0642f7c8b566958af3c6437c1eab74b8d9ef8a65b5e064ee7f415082060" ) From e650cf640dab3b00a1689fd2df6266f1b1eeb867 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 10:42:30 +0100 Subject: [PATCH 068/115] fix(test): configure model proxy artifact custody --- .../projects/guide_compilation/runtime_fixtures.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/tests/projects/guide_compilation/runtime_fixtures.py b/backend/tests/projects/guide_compilation/runtime_fixtures.py index 8b128607c..606fdf2db 100644 --- a/backend/tests/projects/guide_compilation/runtime_fixtures.py +++ b/backend/tests/projects/guide_compilation/runtime_fixtures.py @@ -133,7 +133,18 @@ async def publish_scripted_model_profile(client, project_id, configuration): def configure_scripted_model_transport(monkeypatch, socket_path: Path) -> None: """Enable the typed setup-model session without providing a live transport.""" assert socket_path.is_absolute() and not socket_path.exists() - monkeypatch.setenv("WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET", str(socket_path)) + fixture_root = socket_path.parent + values = { + "ENVIRONMENT": "test", + "ARTIFACT_STORE_BACKEND": "local", + "ARTIFACT_LOCAL_ROOT": str(fixture_root / "durable-artifacts"), + "ARTIFACT_SCRATCH_ROOT": str(fixture_root / "artifact-scratch"), + "CHECKER_MODEL_PROXY_SOCKET": str(socket_path), + } + for scope in ("TASK", "PRODUCER", "PROJECT", "DEPLOYMENT"): + values[f"ARTIFACT_ADMISSION_{scope}_MAXIMUM_BYTES"] = str(1024 * 1024) + for name, value in values.items(): + monkeypatch.setenv("WORKSTREAM_" + name, value) get_settings.cache_clear() From 586c2b8f0a10d2416bb184b6818dcad0e8210f2e Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 10:43:23 +0100 Subject: [PATCH 069/115] test(model): keep fixture repairs within structural boundaries --- .ci/auth-boundaries/TEST_STRUCTURE_DEBT.json | 14 +---- .../projects/guide_compilation/helpers.py | 9 ++- .../proposals/test_public_dispatch.py | 20 ++++-- .../test_artifact_internal_authorization.py | 62 +++++++++++-------- 4 files changed, 59 insertions(+), 46 deletions(-) diff --git a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json index d0236eda8..4798f4ae5 100644 --- a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json +++ b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json @@ -74,7 +74,7 @@ }, { "capability": "oversized_authorization_structure", - "content_sha256": "367dcecc045d7db91ffe36e4164052e479d484cd26cb7b424a96155d8e7f529b", + "content_sha256": "508b934117c60535e2bf3aa1e6db8d516e90d98312e427340f2f63206a7601f5", "end_line": 2453, "hard_limit": 1200, "kind": "test_file", @@ -156,18 +156,6 @@ "removal_chunk": "WS-AUTH-003-CLOSE", "start_line": 653 }, - { - "capability": "oversized_authorization_structure", - "content_sha256": "6a1c0791d31f717e7c7cea14c4d106bc2c76ce05a9b456f7f0fd831718f145e8", - "end_line": 1034, - "hard_limit": 120, - "kind": "test_function", - "observed_lines": 140, - "path": "backend/tests/test_artifact_internal_authorization.py", - "qualified_symbol": "test_verification_claim_and_terminal_failures_roll_back_both_sides", - "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 895 - }, { "capability": "oversized_authorization_structure", "content_sha256": "34f9c463ba512e3aecb3dd772bcb52f209a37f16e1b102700d94eb02aa9990db", diff --git a/backend/tests/projects/guide_compilation/helpers.py b/backend/tests/projects/guide_compilation/helpers.py index 0ebde2e40..e41b6dc0f 100644 --- a/backend/tests/projects/guide_compilation/helpers.py +++ b/backend/tests/projects/guide_compilation/helpers.py @@ -457,8 +457,6 @@ async def _seed_artifact_custody( namespace=None, provider_object_ref: str | None = None, ) -> None: - from datetime import datetime, timezone - namespace_values = { "id": "primary", "backend": namespace.backend if namespace else "local", @@ -509,6 +507,13 @@ async def _seed_artifact_custody( integrity_state="unknown", ) ) + await _seed_verified_guide_put(session, values, namespace_values, provider_ref) + + +async def _seed_verified_guide_put(session, values, namespace_values, provider_ref) -> None: + """Arrange the retained put, its terminal receipt and their exact linkage.""" + from datetime import datetime, timezone + await session.execute( insert(ArtifactPutAttempt).values( id=str(PUT_ATTEMPT_ID), diff --git a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py index e5913b531..13a35019d 100644 --- a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py +++ b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py @@ -37,11 +37,8 @@ async def assert_other_actors_cannot_recover(factory, creator, latest_path, endp assert rejected.status_code == 404, rejected.text -@pytest.mark.parametrize("broker_failure", [False, True]) -@pytest.mark.parametrize("manager_handoff", [False, True]) -async def test_manual_dispatch_retains_one_human_request_and_one_execution( - isolated_database_env, monkeypatch, tmp_path, broker_failure, manager_handoff, -): +def configure_manual_dispatch(monkeypatch, tmp_path, broker_failure): + """Wire the scripted worker and a broker with observable delivery attempts.""" from app.core.config import get_settings from app.core import project_agents from app.modules.projects import setup_queue @@ -66,6 +63,19 @@ def enqueue(**values): raise setup_queue.ProjectSetupQueueError("broker unavailable") return values["task_id"] monkeypatch.setattr(setup_queue, "enqueue_project_guide_compilation", enqueue) + return worker, runtime, published + + +@pytest.mark.parametrize("broker_failure", [False, True]) +@pytest.mark.parametrize("manager_handoff", [False, True]) +async def test_manual_dispatch_retains_one_human_request_and_one_execution( + isolated_database_env, monkeypatch, tmp_path, broker_failure, manager_handoff, +): + from app.core.config import get_settings + + worker, runtime, published = configure_manual_dispatch( + monkeypatch, tmp_path, broker_failure + ) async with proposal_case(isolated_database_env) as (_, factory, command, actor, grant): from tests.project_create_fixtures import grant_fixture_admin_role diff --git a/backend/tests/test_artifact_internal_authorization.py b/backend/tests/test_artifact_internal_authorization.py index 14f113ffa..fc5a0c38e 100644 --- a/backend/tests/test_artifact_internal_authorization.py +++ b/backend/tests/test_artifact_internal_authorization.py @@ -891,6 +891,39 @@ async def publish_job(job_id: str) -> None: await engine.dispose() +async def _pending_verification_with_service_authority( + session, settings, namespace, store, tmp_path, resolver, verifier, +): + """Store exact model evidence and return its pending verification job.""" + session.add_all((*resolver, *verifier)) + await session.commit() + relationships = await seed_checker_output_relationships(session, namespace) + async with minted_source( + tmp_path / "atomic-verify", b'{"verified":true}', media_type="application/json" + ) as source: + _, _, _, admission = await _admit_checker_output( + session, settings, namespace, source, relationships=relationships) + assert ( + await ArtifactStorageOrchestrator( + session, + store, + namespace, + settings, + _internal_authority(session, ServiceIdentity.ARTIFACT_PUT_RESOLVER), + ).execute_committed_put( + attempt_id=admission.attempt_id, + source=source, + ) + == "stored_pending_verification" + ) + + job = (await session.scalars(select(ArtifactVerificationJob).where( + ArtifactVerificationJob.originating_put_attempt_id == str(admission.attempt_id)))).one() + job_id = UUID(job.id) + await session.rollback() + return job, job_id + + @pytest.mark.asyncio async def test_verification_claim_and_terminal_failures_roll_back_both_sides( isolated_database_env: str, @@ -905,32 +938,9 @@ async def test_verification_claim_and_terminal_failures_roll_back_both_sides( bootstrap, store = _local_store(settings, namespace) try: async with factory() as session: - session.add_all((*resolver, *verifier)) - await session.commit() - relationships = await seed_checker_output_relationships(session, namespace) - async with minted_source( - tmp_path / "atomic-verify", b'{"verified":true}', media_type="application/json" - ) as source: - _, _, _, admission = await _admit_checker_output( - session, settings, namespace, source, relationships=relationships) - assert ( - await ArtifactStorageOrchestrator( - session, - store, - namespace, - settings, - _internal_authority(session, ServiceIdentity.ARTIFACT_PUT_RESOLVER), - ).execute_committed_put( - attempt_id=admission.attempt_id, - source=source, - ) - == "stored_pending_verification" - ) - - job = (await session.scalars(select(ArtifactVerificationJob).where( - ArtifactVerificationJob.originating_put_attempt_id == str(admission.attempt_id)))).one() - job_id = UUID(job.id) - await session.rollback() + job, job_id = await _pending_verification_with_service_authority( + session, settings, namespace, store, tmp_path, resolver, verifier, + ) claim = ArtifactStorageOrchestrator( session, store, From 2c9874b7a21ae823dc384c4d8692786afb2884cb Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 10:43:56 +0100 Subject: [PATCH 070/115] test(schema): pin repaired model usage head --- backend/tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 43b45a332..159e6bcc3 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,7 +23,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "20996fd2588683c112ca450338c6e6c128b934c6997acf42660788b8152f3af7" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "482698a75dd54023f02dd1a6c86c14e39af34a8513adeb3d227501d9a4c14e13" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", From be44e835208fe164bd7e1979e68a5b3d033df754 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 10:56:29 +0100 Subject: [PATCH 071/115] test(model-usage): force known-response callback race --- .../test_composed_execution.py | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/backend/tests/checkers_model_calls/test_composed_execution.py b/backend/tests/checkers_model_calls/test_composed_execution.py index 7f89296a9..9e00606b5 100644 --- a/backend/tests/checkers_model_calls/test_composed_execution.py +++ b/backend/tests/checkers_model_calls/test_composed_execution.py @@ -194,6 +194,7 @@ async def publish(_factory, project_id, registry): await provision_post_submit_service(h.factory, ServiceIdentity.ARTIFACT_CHECKER_OUTPUT) creates = [] lost_replies = [] + provider_accepted = asyncio.Event() class Provider: async def create(self, request): @@ -205,6 +206,7 @@ async def create(self, request): ) failed = terminal_mode == "failed" and len(creates) == 1 reference = CheckerModelResponseReference(f"resp_composed_{len(creates)}") + provider_accepted.set() return CheckerModelCreateResult( CheckerModelCreateOutcome.ACCEPTED, known_response=reference, @@ -274,12 +276,24 @@ async def execute(self, request, grant, *, model_grant=None): prompt="Evaluate the verified work", ) if concurrent_callback: - replies = await asyncio.gather(proxy.handle(callback), proxy.handle(callback)) - assert sorted(reply.outcome for reply in replies) == [ - "completed", - "unavailable", - ] - reply = next(reply for reply in replies if reply.outcome == "completed") + first = asyncio.create_task(proxy.handle(callback)) + await asyncio.wait_for(provider_accepted.wait(), timeout=5) + for _ in range(500): + async with h.factory() as session: + known = await session.scalar( + select(ModelUsageEffect.id).where( + ModelUsageEffect.provider_response_id.is_not(None) + ) + ) + if known is not None: + break + await asyncio.sleep(0.01) + assert known is not None + second = asyncio.create_task(proxy.handle(callback)) + replies = await asyncio.gather(first, second) + assert [reply.outcome for reply in replies] == ["completed", "completed"] + assert replies[0].receipt_digest == replies[1].receipt_digest + reply = replies[0] else: reply = await proxy.handle(callback) if terminal_mode is not None and self.invocations == 1: From e8c21d1ea5653809c2a01ba56f6fd69b8761e8ec Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:10:58 +0100 Subject: [PATCH 072/115] test(guide): use complete API identity and rate-control fixtures --- .../proposals/public_support.py | 16 ++++++++++++++++ .../proposals/test_public_dispatch.py | 5 +++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/backend/tests/projects/guide_compilation/proposals/public_support.py b/backend/tests/projects/guide_compilation/proposals/public_support.py index 597bb28a9..36dd1e718 100644 --- a/backend/tests/projects/guide_compilation/proposals/public_support.py +++ b/backend/tests/projects/guide_compilation/proposals/public_support.py @@ -1,14 +1,17 @@ """Real HTTP/SQL proposal owner with only verified identity admission supplied.""" from contextlib import asynccontextmanager +from time import time from httpx import ASGITransport, AsyncClient from app.api.deps.authorization import enforce_human_authorization_read, get_authorization_actor +from app.api.deps.auth import get_auth_verification_result from app.db.session import get_db_session from app.main import create_app from app.modules.actors.models import ActorIdentityLink, ActorProfile from app.modules.actors.service import ResolvedActor +from app.schemas.auth import AuthVerificationResult, VerifiedIssuerToken @asynccontextmanager @@ -23,9 +26,22 @@ async def database(): yield session async def identity(): return resolved + async def verified_identity(): + now = int(time()) + return AuthVerificationResult(token=VerifiedIssuerToken( + issuer=resolved.identity_link.issuer, + subject=resolved.identity_link.subject, + subject_kind=resolved.profile.actor_kind, + audience=("workstream",), + issued_at=now, + expires_at=now + 300, + token_id="proposal-fixture", + scopes=frozenset(), + )) app = create_app() app.dependency_overrides[get_db_session] = database app.dependency_overrides[get_authorization_actor] = identity + app.dependency_overrides[get_auth_verification_result] = verified_identity app.dependency_overrides[enforce_human_authorization_read] = lambda: None async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: yield client diff --git a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py index 13a35019d..841e5b49a 100644 --- a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py +++ b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py @@ -8,6 +8,7 @@ from app.core.identifiers import new_record_id from app.modules.projects.api.guide_compilation import ProjectGuideCompilationDelivery from app.modules.projects.api.setup_identity import project_guide_compilation_task_id +from tests.projects.client_fixtures import project_database_env as project_database_env from tests.projects.guide_compilation.runtime_fixtures import ( ScriptedGuideRuntime, configure_scripted_model_transport, @@ -69,14 +70,14 @@ def enqueue(**values): @pytest.mark.parametrize("broker_failure", [False, True]) @pytest.mark.parametrize("manager_handoff", [False, True]) async def test_manual_dispatch_retains_one_human_request_and_one_execution( - isolated_database_env, monkeypatch, tmp_path, broker_failure, manager_handoff, + project_database_env, monkeypatch, tmp_path, broker_failure, manager_handoff, ): from app.core.config import get_settings worker, runtime, published = configure_manual_dispatch( monkeypatch, tmp_path, broker_failure ) - async with proposal_case(isolated_database_env) as (_, factory, command, actor, grant): + async with proposal_case(project_database_env) as (_, factory, command, actor, grant): from tests.project_create_fixtures import grant_fixture_admin_role async with factory() as session, session.begin(): From 6ee0a29898ef1387cc057db2e3912b0bf7ae48f7 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:14:31 +0100 Subject: [PATCH 073/115] fix(model-usage): bind every paid transition authority --- backend/alembic/versions/0062_model_usage.py | 288 +++++++-- .../tests/checker_output_custody_helpers.py | 4 +- .../model_usage_boundary_support.py | 594 ++++++++++++++++++ ...test_model_call_transactions_postgresql.py | 482 +------------- ...test_model_invocation_grants_postgresql.py | 41 +- .../test_model_shared_setup_postgresql.py | 2 +- ...est_model_terminal_authority_postgresql.py | 4 +- backend/tests/test_ci_lane_catalogue.py | 1 + 8 files changed, 888 insertions(+), 528 deletions(-) create mode 100644 backend/tests/checkers_model_calls/model_usage_boundary_support.py diff --git a/backend/alembic/versions/0062_model_usage.py b/backend/alembic/versions/0062_model_usage.py index dd0f59bd1..7f81d26e8 100644 --- a/backend/alembic/versions/0062_model_usage.py +++ b/backend/alembic/versions/0062_model_usage.py @@ -124,6 +124,9 @@ def _upgrade_invocation_grants() -> None: CREATE FUNCTION public.guard_checker_model_invocation_grant() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ DECLARE authority public.audit_events%ROWTYPE; +DECLARE authority_actor public.actor_profiles%ROWTYPE; +DECLARE authority_facts jsonb; +DECLARE authority_resource_digest varchar; DECLARE task_project uuid; DECLARE assignment_task uuid; DECLARE assignment_project uuid; @@ -192,18 +195,37 @@ def _upgrade_invocation_grants() -> None: SELECT * INTO authority FROM public.audit_events WHERE id=NEW.authorization_evidence_id FOR SHARE; + authority_facts:=pg_catalog.jsonb_build_object( + 'execution',NEW.execution_facts,'slot',NEW.paid_slot, + 'external_request_digest',NEW.external_request_digest, + 'token_hash',NEW.token_hash); + authority_resource_digest:=public.model_usage_json_digest( + pg_catalog.jsonb_build_object('resource_context',pg_catalog.jsonb_build_object( + 'resource_type','checker_model_call', + 'resource_id',(NEW.execution_facts->>'evaluation_request_id')::uuid, + 'scope_project_id',NEW.project_id, + 'action_id','checker.model_call.grant','facts',authority_facts, + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action','checker.model_call.grant','facts',authority_facts))))); + SELECT * INTO authority_actor FROM public.actor_profiles + WHERE id=authority.actor_id::uuid FOR SHARE; IF authority.id IS NULL OR + authority_actor.id IS NULL OR authority.event_domain IS DISTINCT FROM 'authority' OR authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR authority.action_id IS DISTINCT FROM 'checker.model_call.grant' OR authority.permission_id IS DISTINCT FROM 'checker.model_call.grant' OR + authority.project_id IS DISTINCT FROM NEW.project_id OR + authority.resource_type IS DISTINCT FROM 'checker_model_call' OR + authority.resource_id IS DISTINCT FROM + NEW.execution_facts->>'evaluation_request_id' OR authority.denial_code IS NOT NULL OR - authority.after_facts::jsonb IS DISTINCT FROM jsonb_build_object( - 'allowed',true, - 'resource_context_digest',authority.after_facts->>'resource_context_digest') OR - coalesce(authority.after_facts->>'resource_context_digest','') - !~ '^sha256:[0-9a-f]{64}$' THEN + authority.after_facts::jsonb IS DISTINCT FROM pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest',authority_resource_digest) OR + authority_actor.actor_kind IS DISTINCT FROM 'service' OR + authority_actor.status IS DISTINCT FROM 'active' OR + authority_actor.service_identity IS DISTINCT FROM 'workstream.checker.post_submit' THEN RAISE EXCEPTION 'checker model invocation grant authority is invalid' USING ERRCODE='23514'; END IF; @@ -1965,6 +1987,14 @@ def downgrade() -> None: DECLARE terminal_facts jsonb; DECLARE terminal_resource_facts jsonb; DECLARE terminal_resource_digest varchar; +DECLARE phase_actor public.actor_profiles%ROWTYPE; +DECLARE phase_execution jsonb; +DECLARE phase_facts jsonb; +DECLARE phase_resource jsonb; +DECLARE phase_resource_digest varchar; +DECLARE phase_request_evidence jsonb; +DECLARE phase_attempt jsonb; +DECLARE retry_terminal_digest varchar; BEGIN IF TG_OP='DELETE' THEN RAISE EXCEPTION 'checker model call attempts cannot be deleted' USING ERRCODE='55000'; @@ -1985,6 +2015,20 @@ def downgrade() -> None: END IF; SELECT * INTO checker_envelope FROM public.checker_model_usage_envelopes WHERE id=slot.envelope_id FOR UPDATE; + terminal_service:='workstream.checker.post_submit'; + terminal_resource_type:='checker_model_call'; + terminal_slot:=pg_catalog.jsonb_build_object( + 'project_id',slot.project_id,'assignment_id',slot.assignment_id, + 'submission_id',slot.submission_id,'submission_version',slot.submission_version, + 'requirement_id',slot.requirement_id, + 'registry_entry_digest',slot.registry_entry_digest, + 'configuration_sha256',slot.configuration_sha256, + 'profile_id',slot.profile_id,'profile_version',slot.profile_version, + 'profile_digest',slot.profile_digest,'slot_index',slot.slot_index); + terminal_effect:=pg_catalog.jsonb_build_object( + 'project_id',slot.project_id,'task_id',slot.task_id,'slot_id',slot.id, + 'slot_key',terminal_slot,'attempt_id',NEW.id, + 'internal_call_request_id',NEW.internal_call_request_id); ELSE prepare_action:='project.guide_compilation.model.prepare'; dispatch_action:='project.guide_compilation.model.dispatch'; @@ -2000,21 +2044,123 @@ def downgrade() -> None: END IF; SELECT * INTO setup_envelope FROM public.setup_model_usage_envelopes WHERE id=setup_slot.envelope_id FOR UPDATE; + terminal_service:='workstream.project.setup'; + terminal_resource_type:='setup_model_usage'; + terminal_slot:=pg_catalog.jsonb_build_object( + 'compilation_attempt_id',setup_slot.compilation_attempt_id, + 'project_id',setup_slot.project_id,'guide_id',setup_slot.guide_id, + 'source_snapshot_id',setup_slot.source_snapshot_id, + 'setup_run_id',setup_slot.setup_run_id, + 'setup_generation',setup_slot.setup_generation, + 'runtime_configuration_hash',setup_slot.runtime_configuration_hash, + 'profile_id',setup_slot.profile_id,'profile_version',setup_slot.profile_version, + 'profile_digest',setup_slot.profile_digest,'phase',setup_slot.phase, + 'call_index',setup_slot.call_index); + terminal_effect:=pg_catalog.jsonb_build_object( + 'project_id',setup_slot.project_id,'slot_id',setup_slot.id, + 'slot_key',terminal_slot,'effect_id',NEW.id, + 'internal_call_request_id',NEW.internal_call_request_id); END IF; IF profile.id IS NULL THEN RAISE EXCEPTION 'invalid checker model call attempt ownership' USING ERRCODE='23514'; END IF; SELECT * INTO prepare_authority FROM public.audit_events WHERE id=NEW.prepared_authorization_evidence_id FOR SHARE; + SELECT * INTO phase_actor FROM public.actor_profiles + WHERE id=prepare_authority.actor_id::uuid FOR SHARE; + IF NEW.checker_slot_id IS NULL THEN + phase_facts:=pg_catalog.jsonb_build_object( + 'slot',terminal_slot,'request_sha256',NEW.request_sha256); + phase_resource:=pg_catalog.jsonb_build_object( + 'resource_type','setup_model_usage', + 'resource_id',setup_slot.compilation_attempt_id, + 'scope_project_id',setup_slot.project_id, + 'action_id',prepare_action,'facts',phase_facts, + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action',prepare_action,'facts',phase_facts))); + ELSIF NEW.attempt_number=1 THEN + SELECT grant_row.execution_facts INTO phase_execution + FROM public.checker_model_invocation_grants grant_row + WHERE grant_row.project_id=slot.project_id AND + grant_row.paid_slot=terminal_slot AND + grant_row.execution_facts->>'evaluation_request_id'=prepare_authority.resource_id + ORDER BY grant_row.issued_at DESC,grant_row.id DESC LIMIT 1; + phase_facts:=pg_catalog.jsonb_build_object( + 'execution',phase_execution,'slot',terminal_slot, + 'request_sha256',NEW.request_sha256); + phase_resource:=pg_catalog.jsonb_build_object( + 'resource_type','checker_model_call', + 'resource_id',(phase_execution->>'evaluation_request_id')::uuid, + 'scope_project_id',slot.project_id, + 'action_id',prepare_action,'facts',phase_facts, + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action',prepare_action,'facts',phase_facts))); + ELSE + SELECT * INTO parent FROM public.model_usage_effects + WHERE id=NEW.parent_attempt_id FOR SHARE; + retry_terminal_digest:=CASE parent.state + WHEN 'known_no_effect' THEN public.model_usage_json_digest( + pg_catalog.jsonb_build_object( + 'state','known_no_effect','proof_id',parent.no_effect_proof_id, + 'proof_digest',parent.no_effect_proof_digest, + 'observed_at', + to_char(parent.no_effect_observed_at AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS') || + CASE WHEN rtrim(to_char(parent.no_effect_observed_at AT TIME ZONE 'UTC','US'),'0')='' + THEN '' ELSE '.' || + rtrim(to_char(parent.no_effect_observed_at AT TIME ZONE 'UTC','US'),'0') END || + '+00:00')) + WHEN 'known_failed' THEN public.model_usage_json_digest( + pg_catalog.jsonb_build_object( + 'state','known_failed','provider_response_id',parent.provider_response_id, + 'response_content_sha256',parent.response_content_sha256, + 'usage',pg_catalog.jsonb_build_object( + 'input_tokens',parent.input_tokens, + 'cached_input_tokens',parent.cached_input_tokens, + 'output_tokens',parent.output_tokens, + 'reasoning_tokens',parent.reasoning_tokens), + 'settled_nanodollars',parent.settled_nanodollars)) + ELSE NULL END; + phase_facts:=pg_catalog.jsonb_build_object( + 'project_id',slot.project_id,'parent_attempt_id',parent.id, + 'terminal_digest',retry_terminal_digest); + phase_resource:=pg_catalog.jsonb_build_object( + 'resource_type','project_model_administration', + 'resource_id',parent.id,'project_id',slot.project_id, + 'actor_profile_id',prepare_authority.actor_id::uuid, + 'action_id',prepare_action,'facts',phase_facts, + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action',prepare_action,'facts',phase_facts))); + END IF; + phase_resource_digest:=public.model_usage_json_digest( + pg_catalog.jsonb_build_object('resource_context',phase_resource)); IF prepare_authority.id IS NULL OR + phase_actor.id IS NULL OR phase_facts IS NULL OR + (NEW.checker_slot_id IS NOT NULL AND NEW.attempt_number=1 AND + phase_execution IS NULL) OR + (NEW.checker_slot_id IS NOT NULL AND NEW.attempt_number>1 AND + (parent.id IS NULL OR retry_terminal_digest IS NULL)) OR prepare_authority.event_domain IS DISTINCT FROM 'authority' OR prepare_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR prepare_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR prepare_authority.action_id IS DISTINCT FROM prepare_action OR prepare_authority.permission_id IS DISTINCT FROM prepare_action OR + prepare_authority.project_id IS DISTINCT FROM + coalesce(slot.project_id,setup_slot.project_id) OR + prepare_authority.resource_type IS DISTINCT FROM phase_resource->>'resource_type' OR + prepare_authority.resource_id IS DISTINCT FROM phase_resource->>'resource_id' OR prepare_authority.denial_code IS NOT NULL OR - coalesce(prepare_authority.after_facts->>'resource_context_digest','') - !~ '^sha256:[0-9a-f]{64}$' THEN + prepare_authority.after_facts::jsonb IS DISTINCT FROM + pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest',phase_resource_digest) OR + (TG_OP='INSERT' AND NEW.attempt_number=1 AND + (phase_actor.actor_kind IS DISTINCT FROM 'service' OR + phase_actor.status IS DISTINCT FROM 'active' OR + phase_actor.service_identity IS DISTINCT FROM terminal_service)) OR + (TG_OP='INSERT' AND NEW.attempt_number>1 AND + (phase_actor.actor_kind IS DISTINCT FROM 'human' OR + phase_actor.status IS DISTINCT FROM 'active' OR + prepare_authority.matched_grant_id IS NULL)) THEN RAISE EXCEPTION 'model usage prepare authority is invalid' USING ERRCODE='23514'; END IF; maximum_dispatch_reservation := profile.maximum_input_tokens::numeric * @@ -2057,20 +2203,6 @@ def downgrade() -> None: 'output_tokens',NEW.output_tokens,'reasoning_tokens',NEW.reasoning_tokens) ELSE NULL END; IF NEW.checker_slot_id IS NOT NULL THEN - terminal_service:='workstream.checker.post_submit'; - terminal_resource_type:='checker_model_call'; - terminal_slot:=pg_catalog.jsonb_build_object( - 'project_id',slot.project_id,'assignment_id',slot.assignment_id, - 'submission_id',slot.submission_id,'submission_version',slot.submission_version, - 'requirement_id',slot.requirement_id, - 'registry_entry_digest',slot.registry_entry_digest, - 'configuration_sha256',slot.configuration_sha256, - 'profile_id',slot.profile_id,'profile_version',slot.profile_version, - 'profile_digest',slot.profile_digest,'slot_index',slot.slot_index); - terminal_effect:=pg_catalog.jsonb_build_object( - 'project_id',slot.project_id,'task_id',slot.task_id,'slot_id',slot.id, - 'slot_key',terminal_slot,'attempt_id',NEW.id, - 'internal_call_request_id',NEW.internal_call_request_id); IF NEW.state IN ('completed','known_failed') THEN terminal_digest:=public.model_usage_json_digest(pg_catalog.jsonb_build_object( 'status',NEW.state,'provider_response_id',NEW.provider_response_id, @@ -2078,22 +2210,6 @@ def downgrade() -> None: 'response_evidence',terminal_evidence,'usage',terminal_usage)); END IF; ELSE - terminal_service:='workstream.project.setup'; - terminal_resource_type:='setup_model_usage'; - terminal_slot:=pg_catalog.jsonb_build_object( - 'compilation_attempt_id',setup_slot.compilation_attempt_id, - 'project_id',setup_slot.project_id,'guide_id',setup_slot.guide_id, - 'source_snapshot_id',setup_slot.source_snapshot_id, - 'setup_run_id',setup_slot.setup_run_id, - 'setup_generation',setup_slot.setup_generation, - 'runtime_configuration_hash',setup_slot.runtime_configuration_hash, - 'profile_id',setup_slot.profile_id,'profile_version',setup_slot.profile_version, - 'profile_digest',setup_slot.profile_digest,'phase',setup_slot.phase, - 'call_index',setup_slot.call_index); - terminal_effect:=pg_catalog.jsonb_build_object( - 'project_id',setup_slot.project_id,'slot_id',setup_slot.id, - 'slot_key',terminal_slot,'effect_id',NEW.id, - 'internal_call_request_id',NEW.internal_call_request_id); IF NEW.state IN ('completed','known_failed') THEN terminal_digest:=public.model_usage_json_digest(pg_catalog.jsonb_build_object( 'status','completed','provider_response_id',NEW.provider_response_id, @@ -2209,15 +2325,69 @@ def downgrade() -> None: configuration_digest=NEW.budget_configuration_digest FOR SHARE; SELECT * INTO account FROM public.model_usage_budget_accounts WHERE project_id=coalesce(slot.project_id,setup_slot.project_id) FOR SHARE; + SELECT * INTO phase_actor FROM public.actor_profiles + WHERE id=dispatch_authority.actor_id::uuid FOR SHARE; + phase_request_evidence:=pg_catalog.jsonb_build_object( + 'binding_id',NEW.request_binding_id,'content_id',NEW.request_content_id, + 'put_attempt_id',NEW.request_put_attempt_id, + 'verification_receipt_id',NEW.request_verification_receipt_id, + 'content_sha256',NEW.request_content_sha256, + 'byte_count',NEW.request_byte_count,'media_type','application/json'); + IF NEW.checker_slot_id IS NOT NULL THEN + phase_attempt:=pg_catalog.jsonb_build_object( + 'slot_id',slot.id,'slot_key',terminal_slot,'attempt_id',NEW.id, + 'attempt_number',NEW.attempt_number, + 'internal_call_request_id',NEW.internal_call_request_id, + 'request_sha256',NEW.request_sha256,'state','prepared','replayed',false); + SELECT grant_row.execution_facts INTO phase_execution + FROM public.checker_model_invocation_grant_calls grant_call + JOIN public.checker_model_invocation_grants grant_row + ON grant_row.id=grant_call.grant_id + WHERE grant_call.attempt_id=NEW.id AND + grant_call.request_sha256=NEW.request_sha256 AND + grant_row.issued_at<=dispatch_authority.occurred_at + ORDER BY grant_row.issued_at DESC,grant_row.id DESC LIMIT 1; + phase_facts:=pg_catalog.jsonb_build_object( + 'execution',phase_execution,'attempt',phase_attempt, + 'request_evidence',phase_request_evidence); + ELSE + phase_attempt:=pg_catalog.jsonb_build_object( + 'envelope_id',setup_envelope.id,'slot_id',setup_slot.id, + 'slot_key',terminal_slot,'effect_id',NEW.id, + 'attempt_number',NEW.attempt_number, + 'internal_call_request_id',NEW.internal_call_request_id, + 'request_sha256',NEW.request_sha256,'state','prepared','replayed',false); + phase_facts:=pg_catalog.jsonb_build_object( + 'prepared',phase_attempt,'request_evidence',phase_request_evidence); + END IF; + phase_resource:=pg_catalog.jsonb_build_object( + 'resource_type',terminal_resource_type,'resource_id',NEW.id, + 'scope_project_id',coalesce(slot.project_id,setup_slot.project_id), + 'action_id',dispatch_action,'facts',phase_facts, + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action',dispatch_action,'facts',phase_facts))); + phase_resource_digest:=public.model_usage_json_digest( + pg_catalog.jsonb_build_object('resource_context',phase_resource)); IF dispatch_authority.id IS NULL OR + phase_actor.id IS NULL OR phase_facts IS NULL OR + (NEW.checker_slot_id IS NOT NULL AND phase_execution IS NULL) OR dispatch_authority.event_domain IS DISTINCT FROM 'authority' OR dispatch_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR dispatch_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR dispatch_authority.action_id IS DISTINCT FROM dispatch_action OR dispatch_authority.permission_id IS DISTINCT FROM dispatch_action OR + dispatch_authority.project_id IS DISTINCT FROM + coalesce(slot.project_id,setup_slot.project_id) OR + dispatch_authority.resource_type IS DISTINCT FROM terminal_resource_type OR + dispatch_authority.resource_id IS DISTINCT FROM NEW.id::text OR dispatch_authority.denial_code IS NOT NULL OR - coalesce(dispatch_authority.after_facts->>'resource_context_digest','') - !~ '^sha256:[0-9a-f]{64}$' OR + dispatch_authority.after_facts::jsonb IS DISTINCT FROM + pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest',phase_resource_digest) OR + (TG_OP='UPDATE' AND OLD.state='prepared' AND + (phase_actor.actor_kind IS DISTINCT FROM 'service' OR + phase_actor.status IS DISTINCT FROM 'active' OR + phase_actor.service_identity IS DISTINCT FROM terminal_service)) OR NOT request_complete OR budget.id IS NULL OR budget.project_id<>coalesce(slot.project_id,setup_slot.project_id) OR (TG_OP='UPDATE' AND OLD.state='prepared' AND @@ -2290,15 +2460,39 @@ def downgrade() -> None: ELSIF NEW.state='uncertain' THEN SELECT * INTO observation_authority FROM public.audit_events WHERE id=NEW.observation_authorization_evidence_id FOR SHARE; + SELECT * INTO phase_actor FROM public.actor_profiles + WHERE id=observation_authority.actor_id::uuid FOR SHARE; + phase_facts:=pg_catalog.jsonb_build_object( + 'effect',terminal_effect,'provider_response_id',NEW.provider_response_id, + 'observation_digest',NEW.uncertainty_digest); + phase_resource:=pg_catalog.jsonb_build_object( + 'resource_type',terminal_resource_type,'resource_id',NEW.id, + 'scope_project_id',coalesce(slot.project_id,setup_slot.project_id), + 'action_id',observation_action, + 'facts',pg_catalog.jsonb_strip_nulls(phase_facts), + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action',observation_action,'facts',phase_facts))); + phase_resource_digest:=public.model_usage_json_digest( + pg_catalog.jsonb_build_object('resource_context',phase_resource)); IF observation_authority.id IS NULL OR + phase_actor.id IS NULL OR observation_authority.event_domain IS DISTINCT FROM 'authority' OR observation_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR observation_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR observation_authority.action_id IS DISTINCT FROM observation_action OR observation_authority.permission_id IS DISTINCT FROM observation_action OR + observation_authority.project_id IS DISTINCT FROM + coalesce(slot.project_id,setup_slot.project_id) OR + observation_authority.resource_type IS DISTINCT FROM terminal_resource_type OR + observation_authority.resource_id IS DISTINCT FROM NEW.id::text OR observation_authority.denial_code IS NOT NULL OR - coalesce(observation_authority.after_facts->>'resource_context_digest','') - !~ '^sha256:[0-9a-f]{64}$' OR + observation_authority.after_facts::jsonb IS DISTINCT FROM + pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest',phase_resource_digest) OR + (TG_OP='UPDATE' AND OLD.state='dispatching' AND + (phase_actor.actor_kind IS DISTINCT FROM 'service' OR + phase_actor.status IS DISTINCT FROM 'active' OR + phase_actor.service_identity IS DISTINCT FROM terminal_service)) OR NEW.uncertainty_digest !~ '^sha256:[0-9a-f]{64}$' OR NEW.observation_authorization_evidence_id IS NULL OR NEW.response_binding_id IS NOT NULL OR NEW.response_content_id IS NOT NULL OR @@ -2325,6 +2519,10 @@ def downgrade() -> None: terminal_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR terminal_authority.action_id IS DISTINCT FROM terminal_action OR terminal_authority.permission_id IS DISTINCT FROM terminal_action OR + terminal_authority.project_id IS DISTINCT FROM + coalesce(slot.project_id,setup_slot.project_id) OR + terminal_authority.resource_type IS DISTINCT FROM terminal_resource_type OR + terminal_authority.resource_id IS DISTINCT FROM NEW.id::text OR terminal_authority.denial_code IS NOT NULL OR terminal_authority.after_facts::jsonb IS DISTINCT FROM pg_catalog.jsonb_build_object( @@ -2351,6 +2549,10 @@ def downgrade() -> None: terminal_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR terminal_authority.action_id IS DISTINCT FROM terminal_action OR terminal_authority.permission_id IS DISTINCT FROM terminal_action OR + terminal_authority.project_id IS DISTINCT FROM + coalesce(slot.project_id,setup_slot.project_id) OR + terminal_authority.resource_type IS DISTINCT FROM terminal_resource_type OR + terminal_authority.resource_id IS DISTINCT FROM NEW.id::text OR terminal_authority.denial_code IS NOT NULL OR terminal_authority.after_facts::jsonb IS DISTINCT FROM pg_catalog.jsonb_build_object( @@ -2378,6 +2580,10 @@ def downgrade() -> None: terminal_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR terminal_authority.action_id IS DISTINCT FROM terminal_action OR terminal_authority.permission_id IS DISTINCT FROM terminal_action OR + terminal_authority.project_id IS DISTINCT FROM + coalesce(slot.project_id,setup_slot.project_id) OR + terminal_authority.resource_type IS DISTINCT FROM terminal_resource_type OR + terminal_authority.resource_id IS DISTINCT FROM NEW.id::text OR terminal_authority.denial_code IS NOT NULL OR terminal_authority.after_facts::jsonb IS DISTINCT FROM pg_catalog.jsonb_build_object( diff --git a/backend/tests/checker_output_custody_helpers.py b/backend/tests/checker_output_custody_helpers.py index 52df710d5..c215fe5a7 100644 --- a/backend/tests/checker_output_custody_helpers.py +++ b/backend/tests/checker_output_custody_helpers.py @@ -73,9 +73,7 @@ artifact_admission_limit_settings, artifact_preparation_limits, ) -from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( - BoundaryPorts, -) +from tests.checkers_model_calls.model_usage_boundary_support import BoundaryPorts from tests.checkers_model_calls.test_public_api import profile_spec from tests.checkers.execution.support import provision_post_submit_service from tests.checkers.external.support import post_registry_spec diff --git a/backend/tests/checkers_model_calls/model_usage_boundary_support.py b/backend/tests/checkers_model_calls/model_usage_boundary_support.py new file mode 100644 index 000000000..2b356f73f --- /dev/null +++ b/backend/tests/checkers_model_calls/model_usage_boundary_support.py @@ -0,0 +1,594 @@ +"""Canonical fake owner boundaries for real MODEL-USAGE PostgreSQL tests.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import json +from uuid import UUID + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.authorization.domain.model_usage import ( + BUDGET_CONFIGURE, + COMPLETE, + DISPATCH, + OBSERVE, + PREPARE, + PROFILE_PROVISION, + RECOVER, + RECOVERY_CHILD_AUTHORIZE, + SETUP_COMPLETE, + SETUP_DISPATCH, + SETUP_OBSERVE, + SETUP_PREPARE, + ModelCallCompleteAuthorityFacts, + ModelCallDispatchAuthorityFacts, + ModelCallObserveAuthorityFacts, + ModelCallPrepareAuthorityFacts, + ModelCallRecoverAuthorityFacts, + ModelCallRecoveryChildAuthorityFacts, + SetupModelCompleteAuthorityFacts, + SetupModelDispatchAuthorityFacts, + SetupModelObserveAuthorityFacts, + SetupModelPrepareAuthorityFacts, + checker_model_call_resource, + project_model_admin_resource, + setup_model_usage_resource, +) +from app.modules.authorization.domain.resource_digest import authorization_resource_digest +from app.modules.model_usage.api import ( + ModelActionEvidence, + ModelCallUnavailable, + ModelProfileSelector, + SetupModelOwnerProfile, + model_action_digest, +) + + +def sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +@dataclass +class BoundaryPorts: + decisions: dict[str, UUID] = field(default_factory=dict) + fresh_authority_evidence: bool = True + request_checks: int = 0 + response_checks: int = 0 + current_checks: int = 0 + events: list[str] = field(default_factory=list) + corrupt_admin_receipt: bool = False + terminal_receipt_change: str | None = None + + def _decision(self, action: str, facts: object) -> ModelActionEvidence: + self.events.append(action) + digest = model_action_digest(action, facts) + evidence_id = ( + new_record_id() + if self.fresh_authority_evidence + else self.decisions.setdefault(digest, new_record_id()) + ) + return ModelActionEvidence(evidence_id=evidence_id, facts_digest=digest) + + async def authorize_profile(self, facts): + return self._decision("profile", facts) + + async def authorize_budget(self, facts): + return self._decision("budget", facts) + + async def authorize_prepare(self, execution, slot, request_sha256): + return self._decision( + "prepare", + { + "execution": execution.model_dump(mode="json"), + "slot": slot.model_dump(mode="json"), + "request_sha256": request_sha256, + }, + ) + + async def authorize_dispatch(self, execution, attempt, request_evidence): + return self._decision( + "dispatch", + { + "execution": execution.model_dump(mode="json"), + "attempt": attempt.model_dump(mode="json"), + "request_evidence": request_evidence.model_dump(mode="json"), + }, + ) + + async def authorize_recover(self, execution, effect): + return self._decision( + "recover", + { + "execution": execution.model_dump(mode="json"), + "effect": effect.model_dump(mode="json"), + }, + ) + + async def authorize_observe(self, effect, provider_response_id, observation_digest): + return self._decision( + "observe", + { + "effect": effect.model_dump(mode="json"), + "provider_response_id": provider_response_id, + "observation_digest": observation_digest, + }, + ) + + async def authorize_child(self, project_id, parent_attempt_id, terminal_digest): + return self._decision( + "child", + { + "project_id": str(project_id), + "parent_attempt_id": str(parent_attempt_id), + "terminal_digest": terminal_digest, + }, + ) + + async def authorize_complete(self, effect, response_evidence, terminal_digest): + return self._decision( + "complete", + { + "effect": effect.model_dump(mode="json"), + "response_evidence": ( + response_evidence.model_dump(mode="json") + if response_evidence is not None + else None + ), + "terminal_digest": terminal_digest, + }, + ) + + async def authorize_setup_prepare(self, slot, request_sha256): + return self._decision( + "setup_prepare", + {"slot": slot.model_dump(mode="json"), "request_sha256": request_sha256}, + ) + + async def authorize_setup_dispatch(self, prepared, request_evidence): + return self._decision( + "setup_dispatch", + { + "prepared": prepared.model_dump(mode="json"), + "request_evidence": request_evidence.model_dump(mode="json"), + }, + ) + + async def authorize_setup_observe(self, effect, provider_response_id, observation_digest): + return self._decision( + "setup_observe", + { + "effect": effect.model_dump(mode="json"), + "provider_response_id": provider_response_id, + "observation_digest": observation_digest, + }, + ) + + async def authorize_setup_complete(self, effect, response_evidence, terminal_digest): + return self._decision( + "setup_complete", + { + "effect": effect.model_dump(mode="json"), + "response_evidence": ( + response_evidence.model_dump(mode="json") + if response_evidence is not None + else None + ), + "terminal_digest": terminal_digest, + }, + ) + + async def require_setup_request(self, prepared, evidence): + self.request_checks += 1 + + async def require_setup_response(self, effect_id, evidence): + self.response_checks += 1 + + async def require_current(self, facts, slot): + self.current_checks += 1 + self.events.append("current") + + async def require_recovery_current(self, slot, parent_attempt_id): + self.current_checks += 1 + self.events.append("recovery_current") + + async def require_request(self, attempt, evidence): + self.request_checks += 1 + + async def require_response(self, attempt_id, evidence): + self.response_checks += 1 + + +class DatabaseBoundaryPorts: + """Persist fake boundary receipts so isolated PostgreSQL exercises real ancestry FKs.""" + + def __init__(self, session: AsyncSession, ports: BoundaryPorts): + self._session = session + self._ports = ports + + async def _authority( + self, + evidence, + *, + action: str, + permission: str, + actor_id=None, + resource=None, + resource_digest=None, + service_identity: str | None = None, + matched_grant_id: UUID | None = None, + ): + actor = str(actor_id or new_record_id()) + if actor_id is not None or service_identity is not None: + await self._session.execute( + text( + "insert into actor_profiles(id,actor_kind,status,service_identity) " + "values (:id,:kind,'active',:identity) on conflict do nothing" + ), + { + "id": actor, + "kind": "service" if service_identity is not None else "human", + "identity": service_identity, + }, + ) + project_id = None + resource_type = None + resource_id = None + if resource is not None: + project_id = getattr(resource, "scope_project_id", None) or getattr( + resource, "project_id", None + ) + resource_type = resource.resource_type + resource_id = resource.resource_id + if resource_digest is None: + resource_digest = authorization_resource_digest(resource) + await self._session.execute( + text( + "insert into audit_events(id,event_domain,event_type,actor_ref_kind,actor_id," + "action_id,permission_id,project_id,resource_type,resource_id,matched_grant_id," + "denial_code,after_facts) values (:id,'authority'," + "'SensitiveAuthorizationAllowed','actor_profile',:actor,:action,:permission," + ":project,:resource_type,:resource_id,:matched_grant,null,cast(:after as json))" + ), + { + "id": evidence.evidence_id, + "actor": actor, + "action": action, + "permission": permission, + "project": project_id, + "resource_type": resource_type, + "resource_id": str(resource_id) if resource_id is not None else None, + "matched_grant": (str(matched_grant_id) if matched_grant_id is not None else None), + "after": json.dumps( + { + "allowed": True, + "resource_context_digest": ( + sha("substituted-authority") + if actor_id is not None and self._ports.corrupt_admin_receipt + else resource_digest or sha("authority") + ), + } + ), + }, + ) + return evidence + + async def authorize_profile(self, facts): + evidence = await self._ports.authorize_profile(facts) + return await self._authority( + evidence, + action="project.model_profile.provision", + permission="project.model_profile.manage", + actor_id=facts.actor_profile_id, + resource_digest=authorization_resource_digest( + project_model_admin_resource( + PROFILE_PROVISION, facts, actor_profile_id=facts.actor_profile_id + ) + ), + ) + + async def authorize_budget(self, facts): + evidence = await self._ports.authorize_budget(facts) + return await self._authority( + evidence, + action="project.model_budget.configure", + permission="project.model_budget.manage", + actor_id=facts.actor_profile_id, + resource_digest=authorization_resource_digest( + project_model_admin_resource( + BUDGET_CONFIGURE, facts, actor_profile_id=facts.actor_profile_id + ) + ), + ) + + async def authorize_prepare(self, execution, slot, request_sha256): + evidence = await self._ports.authorize_prepare(execution, slot, request_sha256) + facts = ModelCallPrepareAuthorityFacts( + execution=execution, slot=slot, request_sha256=request_sha256 + ) + return await self._authority( + evidence, + action="checker.model_call.prepare", + permission="checker.model_call.prepare", + resource=checker_model_call_resource(PREPARE, facts), + service_identity="workstream.checker.post_submit", + ) + + async def authorize_dispatch(self, execution, attempt, request_evidence): + evidence = await self._ports.authorize_dispatch(execution, attempt, request_evidence) + facts = ModelCallDispatchAuthorityFacts( + execution=execution, attempt=attempt, request_evidence=request_evidence + ) + return await self._authority( + evidence, + action="checker.model_call.dispatch", + permission="checker.model_call.dispatch", + resource=checker_model_call_resource(DISPATCH, facts), + service_identity="workstream.checker.post_submit", + ) + + async def authorize_recover(self, execution, effect): + evidence = await self._ports.authorize_recover(execution, effect) + actor_id = new_record_id() + await self._session.execute( + text( + "insert into actor_profiles(id,actor_kind,status,service_identity) " + "values (:id,'service','active','workstream.checker.post_submit')" + ), + {"id": actor_id}, + ) + facts = ModelCallRecoverAuthorityFacts(execution=execution, effect=effect) + return await self._authority( + evidence, + action="checker.model_call.recover", + permission="checker.model_call.recover", + actor_id=actor_id, + resource=checker_model_call_resource(RECOVER, facts), + resource_digest=authorization_resource_digest( + checker_model_call_resource(RECOVER, facts) + ), + ) + + async def authorize_observe(self, effect, provider_response_id, observation_digest): + evidence = await self._ports.authorize_observe( + effect, provider_response_id, observation_digest + ) + facts = ModelCallObserveAuthorityFacts( + effect=effect, + provider_response_id=provider_response_id, + observation_digest=observation_digest, + ) + return await self._authority( + evidence, + action="checker.model_call.observe", + permission="checker.model_call.observe", + resource=checker_model_call_resource(OBSERVE, facts), + service_identity="workstream.checker.post_submit", + ) + + async def authorize_child(self, project_id, parent_attempt_id, terminal_digest): + evidence = await self._ports.authorize_child(project_id, parent_attempt_id, terminal_digest) + actor_id = new_record_id() + facts = ModelCallRecoveryChildAuthorityFacts( + project_id=project_id, + parent_attempt_id=parent_attempt_id, + terminal_digest=terminal_digest, + ) + return await self._authority( + evidence, + action="project.model_call.recovery_child.authorize", + permission="project.model_call.recovery_child.authorize", + actor_id=actor_id, + resource=project_model_admin_resource( + RECOVERY_CHILD_AUTHORIZE, facts, actor_profile_id=actor_id + ), + matched_grant_id=new_record_id(), + ) + + async def authorize_complete(self, effect, response_evidence, terminal_digest): + evidence = await self._ports.authorize_complete(effect, response_evidence, terminal_digest) + actor_id = new_record_id() + service_identity = ( + "workstream.project.setup" + if self._ports.terminal_receipt_change == "actor" + else "workstream.checker.post_submit" + ) + await self._session.execute( + text( + "insert into actor_profiles(id,actor_kind,status,service_identity) " + "values (:id,'service','active',:identity)" + ), + {"id": actor_id, "identity": service_identity}, + ) + authority_effect = effect + if self._ports.terminal_receipt_change == "effect": + authority_effect = effect.model_copy(update={"attempt_id": new_record_id()}) + elif self._ports.terminal_receipt_change == "project": + foreign_project = new_record_id() + authority_effect = effect.model_copy( + update={ + "project_id": foreign_project, + "slot_key": effect.slot_key.model_copy(update={"project_id": foreign_project}), + } + ) + facts = ModelCallCompleteAuthorityFacts( + effect=authority_effect, + response_evidence=response_evidence, + terminal_digest=terminal_digest, + ) + action = ( + "checker.model_call.observe" + if self._ports.terminal_receipt_change == "action" + else "checker.model_call.complete" + ) + resource_digest = authorization_resource_digest( + checker_model_call_resource(COMPLETE, facts) + ) + if self._ports.terminal_receipt_change == "digest": + resource_digest = sha("foreign terminal resource") + return await self._authority( + evidence, + action=action, + permission=action, + actor_id=actor_id, + resource=checker_model_call_resource(COMPLETE, facts), + resource_digest=resource_digest, + ) + + async def authorize_setup_prepare(self, slot, request_sha256): + evidence = await self._ports.authorize_setup_prepare(slot, request_sha256) + facts = SetupModelPrepareAuthorityFacts(slot=slot, request_sha256=request_sha256) + return await self._authority( + evidence, + action="project.guide_compilation.model.prepare", + permission="project.guide_compilation.model.prepare", + resource=setup_model_usage_resource(SETUP_PREPARE, facts), + service_identity="workstream.project.setup", + ) + + async def authorize_setup_dispatch(self, prepared, request_evidence): + evidence = await self._ports.authorize_setup_dispatch(prepared, request_evidence) + facts = SetupModelDispatchAuthorityFacts( + prepared=prepared, request_evidence=request_evidence + ) + return await self._authority( + evidence, + action="project.guide_compilation.model.dispatch", + permission="project.guide_compilation.model.dispatch", + resource=setup_model_usage_resource(SETUP_DISPATCH, facts), + service_identity="workstream.project.setup", + ) + + async def authorize_setup_observe(self, effect, provider_response_id, observation_digest): + evidence = await self._ports.authorize_setup_observe( + effect, provider_response_id, observation_digest + ) + facts = SetupModelObserveAuthorityFacts( + effect=effect, + provider_response_id=provider_response_id, + observation_digest=observation_digest, + ) + return await self._authority( + evidence, + action="project.guide_compilation.model.observe", + permission="project.guide_compilation.model.observe", + resource=setup_model_usage_resource(SETUP_OBSERVE, facts), + service_identity="workstream.project.setup", + ) + + async def authorize_setup_complete(self, effect, response_evidence, terminal_digest): + evidence = await self._ports.authorize_setup_complete( + effect, response_evidence, terminal_digest + ) + actor_id = new_record_id() + service_identity = ( + "workstream.checker.post_submit" + if self._ports.terminal_receipt_change == "actor" + else "workstream.project.setup" + ) + await self._session.execute( + text( + "insert into actor_profiles(id,actor_kind,status,service_identity) " + "values (:id,'service','active',:identity)" + ), + {"id": actor_id, "identity": service_identity}, + ) + authority_effect = effect + if self._ports.terminal_receipt_change == "effect": + authority_effect = effect.model_copy(update={"effect_id": new_record_id()}) + elif self._ports.terminal_receipt_change == "project": + foreign_project = new_record_id() + authority_effect = effect.model_copy( + update={ + "project_id": foreign_project, + "slot_key": effect.slot_key.model_copy(update={"project_id": foreign_project}), + } + ) + facts = SetupModelCompleteAuthorityFacts( + effect=authority_effect, + response_evidence=response_evidence, + terminal_digest=terminal_digest, + ) + action = ( + "project.guide_compilation.model.observe" + if self._ports.terminal_receipt_change == "action" + else "project.guide_compilation.model.complete" + ) + resource_digest = authorization_resource_digest( + setup_model_usage_resource(SETUP_COMPLETE, facts) + ) + if self._ports.terminal_receipt_change == "digest": + resource_digest = sha("foreign setup terminal resource") + return await self._authority( + evidence, + action=action, + permission=action, + actor_id=actor_id, + resource=setup_model_usage_resource(SETUP_COMPLETE, facts), + resource_digest=resource_digest, + ) + + async def require_setup_request(self, prepared, evidence): + await self._ports.require_setup_request(prepared, evidence) + await self._artifact(evidence) + + async def require_setup_response(self, effect_id, evidence): + await self._ports.require_setup_response(effect_id, evidence) + await self._artifact(evidence) + + async def require_setup_current(self, slot): + attempt_id = await self._session.scalar( + text( + "select id from project_guide_compilation_attempts where id=:id and project_id=:project and guide_id=:guide and source_snapshot_id=:snapshot and setup_run_id=:run and setup_generation=:generation and runtime_configuration_hash=:runtime and status='compilation_reserved' for update" + ), + { + "id": slot.compilation_attempt_id, + "project": slot.project_id, + "guide": slot.guide_id, + "snapshot": slot.source_snapshot_id, + "run": slot.setup_run_id, + "generation": slot.setup_generation, + "runtime": slot.runtime_configuration_hash, + }, + ) + if attempt_id is None: + raise ModelCallUnavailable("setup_model_owner_unavailable") + return SetupModelOwnerProfile( + selector=ModelProfileSelector( + profile_id=slot.profile_id, + profile_version=slot.profile_version, + profile_digest=slot.profile_digest, + ), + provider="openai_responses", + api="responses.v1", + model_snapshot="gpt-example-2026-10-01", + ) + + async def require_current(self, facts, slot): + await self._ports.require_current(facts, slot) + + async def require_recovery_current(self, slot, parent_attempt_id): + await self._ports.require_recovery_current(slot, parent_attempt_id) + + async def _artifact(self, evidence): + for table_name, value in ( + ("artifact_bindings", evidence.binding_id), + ("artifact_contents", evidence.content_id), + ("artifact_put_attempts", evidence.put_attempt_id), + ("artifact_verification_receipts", evidence.verification_receipt_id), + ): + await self._session.execute( + text(f"insert into {table_name}(id) values (:id) on conflict do nothing"), + {"id": value}, + ) + + async def require_request(self, attempt, evidence): + await self._ports.require_request(attempt, evidence) + await self._artifact(evidence) + + async def require_response(self, attempt_id, evidence): + await self._ports.require_response(attempt_id, evidence) + await self._artifact(evidence) diff --git a/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py index 044467037..084dd2b91 100644 --- a/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py @@ -1,10 +1,9 @@ from __future__ import annotations -from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta, timezone import asyncio import json -from uuid import UUID, uuid4 +from uuid import uuid4 import pytest from sqlalchemy import text @@ -13,23 +12,8 @@ from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id -from app.modules.authorization.domain.model_usage import ( - BUDGET_CONFIGURE, - COMPLETE, - PROFILE_PROVISION, - RECOVER, - SETUP_COMPLETE, - ModelCallCompleteAuthorityFacts, - ModelCallRecoverAuthorityFacts, - SetupModelCompleteAuthorityFacts, - checker_model_call_resource, - project_model_admin_resource, - setup_model_usage_resource, -) -from app.modules.authorization.domain.resource_digest import authorization_resource_digest from app.modules.model_usage.api import ( KnownNoEffectProof, - ModelActionEvidence, ModelBudgetProvisioningFacts, ModelCallBudgetUnavailable, ModelCallConflict, @@ -40,13 +24,10 @@ ModelGenerationSettings, ModelPaidSlotKey, ModelPricing, - ModelProfileSelector, ModelProfileProvisioningFacts, ProjectModelBudgetConfigurationSpec, ProjectModelProfileSpec, SetupModelUsageSlotKey, - SetupModelOwnerProfile, - model_action_digest, model_retry_terminal_digest, model_usage_instant, ) @@ -57,6 +38,10 @@ DatabaseFacts, model_database as _model_database, ) +from tests.checkers_model_calls.model_usage_boundary_support import ( + BoundaryPorts, + DatabaseBoundaryPorts, +) model_database = _model_database @@ -65,463 +50,6 @@ def sha(label: str) -> str: return canonical_json_hash({"label": label}) -@dataclass -class BoundaryPorts: - decisions: dict[str, UUID] = field(default_factory=dict) - fresh_authority_evidence: bool = True - request_checks: int = 0 - response_checks: int = 0 - current_checks: int = 0 - events: list[str] = field(default_factory=list) - corrupt_admin_receipt: bool = False - terminal_receipt_change: str | None = None - - def _decision(self, action: str, facts: object) -> ModelActionEvidence: - self.events.append(action) - digest = model_action_digest(action, facts) - evidence_id = ( - new_record_id() - if self.fresh_authority_evidence - else self.decisions.setdefault(digest, new_record_id()) - ) - return ModelActionEvidence(evidence_id=evidence_id, facts_digest=digest) - - async def authorize_profile(self, facts): - return self._decision("profile", facts) - - async def authorize_budget(self, facts): - return self._decision("budget", facts) - - async def authorize_prepare(self, execution, slot, request_sha256): - return self._decision( - "prepare", - { - "execution": execution.model_dump(mode="json"), - "slot": slot.model_dump(mode="json"), - "request_sha256": request_sha256, - }, - ) - - async def authorize_dispatch(self, execution, attempt, request_evidence): - return self._decision( - "dispatch", - { - "execution": execution.model_dump(mode="json"), - "attempt": attempt.model_dump(mode="json"), - "request_evidence": request_evidence.model_dump(mode="json"), - }, - ) - - async def authorize_recover(self, execution, effect): - return self._decision( - "recover", - { - "execution": execution.model_dump(mode="json"), - "effect": effect.model_dump(mode="json"), - }, - ) - - async def authorize_observe(self, effect, provider_response_id, observation_digest): - return self._decision( - "observe", - { - "effect": effect.model_dump(mode="json"), - "provider_response_id": provider_response_id, - "observation_digest": observation_digest, - }, - ) - - async def authorize_child(self, project_id, parent_attempt_id, terminal_digest): - return self._decision( - "child", - { - "project_id": str(project_id), - "parent_attempt_id": str(parent_attempt_id), - "terminal_digest": terminal_digest, - }, - ) - - async def authorize_complete(self, effect, response_evidence, terminal_digest): - return self._decision( - "complete", - { - "effect": effect.model_dump(mode="json"), - "response_evidence": ( - response_evidence.model_dump(mode="json") - if response_evidence is not None - else None - ), - "terminal_digest": terminal_digest, - }, - ) - - async def authorize_setup_prepare(self, slot, request_sha256): - return self._decision( - "setup_prepare", - {"slot": slot.model_dump(mode="json"), "request_sha256": request_sha256}, - ) - - async def authorize_setup_dispatch(self, prepared, request_evidence): - return self._decision( - "setup_dispatch", - { - "prepared": prepared.model_dump(mode="json"), - "request_evidence": request_evidence.model_dump(mode="json"), - }, - ) - - async def authorize_setup_observe(self, effect, provider_response_id, observation_digest): - return self._decision( - "setup_observe", - { - "effect": effect.model_dump(mode="json"), - "provider_response_id": provider_response_id, - "observation_digest": observation_digest, - }, - ) - - async def authorize_setup_complete(self, effect, response_evidence, terminal_digest): - return self._decision( - "setup_complete", - { - "effect": effect.model_dump(mode="json"), - "response_evidence": ( - response_evidence.model_dump(mode="json") - if response_evidence is not None - else None - ), - "terminal_digest": terminal_digest, - }, - ) - - async def require_setup_request(self, prepared, evidence): - self.request_checks += 1 - - async def require_setup_response(self, effect_id, evidence): - self.response_checks += 1 - - async def require_current(self, facts, slot): - self.current_checks += 1 - self.events.append("current") - - async def require_recovery_current(self, slot, parent_attempt_id): - self.current_checks += 1 - self.events.append("recovery_current") - - async def require_request(self, attempt, evidence): - self.request_checks += 1 - - async def require_response(self, attempt_id, evidence): - self.response_checks += 1 - - -class DatabaseBoundaryPorts: - """Persist fake boundary receipts so isolated PostgreSQL exercises real ancestry FKs.""" - - def __init__(self, session: AsyncSession, ports: BoundaryPorts): - self._session = session - self._ports = ports - - async def _authority( - self, evidence, *, action: str, permission: str, actor_id=None, resource_digest=None - ): - actor = str(actor_id or new_record_id()) - if actor_id is not None: - await self._session.execute( - text("insert into actor_profiles(id) values (:id) on conflict do nothing"), - {"id": actor_id}, - ) - await self._session.execute( - text( - "insert into audit_events(id,event_domain,event_type,actor_ref_kind,actor_id,action_id,permission_id,denial_code,after_facts) values (:id,'authority','SensitiveAuthorizationAllowed','actor_profile',:actor,:action,:permission,null,cast(:after as json))" - ), - { - "id": evidence.evidence_id, - "actor": actor, - "action": action, - "permission": permission, - "after": json.dumps( - { - "allowed": True, - "resource_context_digest": ( - sha("substituted-authority") - if actor_id is not None and self._ports.corrupt_admin_receipt - else resource_digest or sha("authority") - ), - } - ), - }, - ) - return evidence - - async def authorize_profile(self, facts): - evidence = await self._ports.authorize_profile(facts) - return await self._authority( - evidence, - action="project.model_profile.provision", - permission="project.model_profile.manage", - actor_id=facts.actor_profile_id, - resource_digest=authorization_resource_digest( - project_model_admin_resource( - PROFILE_PROVISION, facts, actor_profile_id=facts.actor_profile_id - ) - ), - ) - - async def authorize_budget(self, facts): - evidence = await self._ports.authorize_budget(facts) - return await self._authority( - evidence, - action="project.model_budget.configure", - permission="project.model_budget.manage", - actor_id=facts.actor_profile_id, - resource_digest=authorization_resource_digest( - project_model_admin_resource( - BUDGET_CONFIGURE, facts, actor_profile_id=facts.actor_profile_id - ) - ), - ) - - async def authorize_prepare(self, execution, slot, request_sha256): - evidence = await self._ports.authorize_prepare(execution, slot, request_sha256) - return await self._authority( - evidence, action="checker.model_call.prepare", permission="checker.model_call.prepare" - ) - - async def authorize_dispatch(self, execution, attempt, request_evidence): - evidence = await self._ports.authorize_dispatch(execution, attempt, request_evidence) - return await self._authority( - evidence, - action="checker.model_call.dispatch", - permission="checker.model_call.dispatch", - ) - - async def authorize_recover(self, execution, effect): - evidence = await self._ports.authorize_recover(execution, effect) - actor_id = new_record_id() - await self._session.execute( - text( - "insert into actor_profiles(id,actor_kind,status,service_identity) " - "values (:id,'service','active','workstream.checker.post_submit')" - ), - {"id": actor_id}, - ) - facts = ModelCallRecoverAuthorityFacts(execution=execution, effect=effect) - return await self._authority( - evidence, - action="checker.model_call.recover", - permission="checker.model_call.recover", - actor_id=actor_id, - resource_digest=authorization_resource_digest( - checker_model_call_resource(RECOVER, facts) - ), - ) - - async def authorize_observe(self, effect, provider_response_id, observation_digest): - evidence = await self._ports.authorize_observe( - effect, provider_response_id, observation_digest - ) - return await self._authority( - evidence, action="checker.model_call.observe", permission="checker.model_call.observe" - ) - - async def authorize_child(self, project_id, parent_attempt_id, terminal_digest): - evidence = await self._ports.authorize_child(project_id, parent_attempt_id, terminal_digest) - return await self._authority( - evidence, - action="project.model_call.recovery_child.authorize", - permission="project.model_call.recovery_child.authorize", - ) - - async def authorize_complete(self, effect, response_evidence, terminal_digest): - evidence = await self._ports.authorize_complete(effect, response_evidence, terminal_digest) - actor_id = new_record_id() - service_identity = ( - "workstream.project.setup" - if self._ports.terminal_receipt_change == "actor" - else "workstream.checker.post_submit" - ) - await self._session.execute( - text( - "insert into actor_profiles(id,actor_kind,status,service_identity) " - "values (:id,'service','active',:identity)" - ), - {"id": actor_id, "identity": service_identity}, - ) - authority_effect = effect - if self._ports.terminal_receipt_change == "effect": - authority_effect = effect.model_copy(update={"attempt_id": new_record_id()}) - elif self._ports.terminal_receipt_change == "project": - foreign_project = new_record_id() - authority_effect = effect.model_copy( - update={ - "project_id": foreign_project, - "slot_key": effect.slot_key.model_copy(update={"project_id": foreign_project}), - } - ) - facts = ModelCallCompleteAuthorityFacts( - effect=authority_effect, - response_evidence=response_evidence, - terminal_digest=terminal_digest, - ) - action = ( - "checker.model_call.observe" - if self._ports.terminal_receipt_change == "action" - else "checker.model_call.complete" - ) - resource_digest = authorization_resource_digest( - checker_model_call_resource(COMPLETE, facts) - ) - if self._ports.terminal_receipt_change == "digest": - resource_digest = sha("foreign terminal resource") - return await self._authority( - evidence, - action=action, - permission=action, - actor_id=actor_id, - resource_digest=resource_digest, - ) - - async def authorize_setup_prepare(self, slot, request_sha256): - evidence = await self._ports.authorize_setup_prepare(slot, request_sha256) - return await self._authority( - evidence, - action="project.guide_compilation.model.prepare", - permission="project.guide_compilation.model.prepare", - ) - - async def authorize_setup_dispatch(self, prepared, request_evidence): - evidence = await self._ports.authorize_setup_dispatch(prepared, request_evidence) - return await self._authority( - evidence, - action="project.guide_compilation.model.dispatch", - permission="project.guide_compilation.model.dispatch", - ) - - async def authorize_setup_observe(self, effect, provider_response_id, observation_digest): - evidence = await self._ports.authorize_setup_observe( - effect, provider_response_id, observation_digest - ) - return await self._authority( - evidence, - action="project.guide_compilation.model.observe", - permission="project.guide_compilation.model.observe", - ) - - async def authorize_setup_complete(self, effect, response_evidence, terminal_digest): - evidence = await self._ports.authorize_setup_complete( - effect, response_evidence, terminal_digest - ) - actor_id = new_record_id() - service_identity = ( - "workstream.checker.post_submit" - if self._ports.terminal_receipt_change == "actor" - else "workstream.project.setup" - ) - await self._session.execute( - text( - "insert into actor_profiles(id,actor_kind,status,service_identity) " - "values (:id,'service','active',:identity)" - ), - {"id": actor_id, "identity": service_identity}, - ) - authority_effect = effect - if self._ports.terminal_receipt_change == "effect": - authority_effect = effect.model_copy(update={"effect_id": new_record_id()}) - elif self._ports.terminal_receipt_change == "project": - foreign_project = new_record_id() - authority_effect = effect.model_copy( - update={ - "project_id": foreign_project, - "slot_key": effect.slot_key.model_copy(update={"project_id": foreign_project}), - } - ) - facts = SetupModelCompleteAuthorityFacts( - effect=authority_effect, - response_evidence=response_evidence, - terminal_digest=terminal_digest, - ) - action = ( - "project.guide_compilation.model.observe" - if self._ports.terminal_receipt_change == "action" - else "project.guide_compilation.model.complete" - ) - resource_digest = authorization_resource_digest( - setup_model_usage_resource(SETUP_COMPLETE, facts) - ) - if self._ports.terminal_receipt_change == "digest": - resource_digest = sha("foreign setup terminal resource") - return await self._authority( - evidence, - action=action, - permission=action, - actor_id=actor_id, - resource_digest=resource_digest, - ) - - async def require_setup_request(self, prepared, evidence): - await self._ports.require_setup_request(prepared, evidence) - await self._artifact(evidence) - - async def require_setup_response(self, effect_id, evidence): - await self._ports.require_setup_response(effect_id, evidence) - await self._artifact(evidence) - - async def require_setup_current(self, slot): - attempt_id = await self._session.scalar( - text( - "select id from project_guide_compilation_attempts where id=:id and project_id=:project and guide_id=:guide and source_snapshot_id=:snapshot and setup_run_id=:run and setup_generation=:generation and runtime_configuration_hash=:runtime and status='compilation_reserved' for update" - ), - { - "id": slot.compilation_attempt_id, - "project": slot.project_id, - "guide": slot.guide_id, - "snapshot": slot.source_snapshot_id, - "run": slot.setup_run_id, - "generation": slot.setup_generation, - "runtime": slot.runtime_configuration_hash, - }, - ) - if attempt_id is None: - raise ModelCallUnavailable("setup_model_owner_unavailable") - return SetupModelOwnerProfile( - selector=ModelProfileSelector( - profile_id=slot.profile_id, - profile_version=slot.profile_version, - profile_digest=slot.profile_digest, - ), - provider="openai_responses", - api="responses.v1", - model_snapshot="gpt-example-2026-10-01", - ) - - async def require_current(self, facts, slot): - await self._ports.require_current(facts, slot) - - async def require_recovery_current(self, slot, parent_attempt_id): - await self._ports.require_recovery_current(slot, parent_attempt_id) - - async def _artifact(self, evidence): - for table_name, value in ( - ("artifact_bindings", evidence.binding_id), - ("artifact_contents", evidence.content_id), - ("artifact_put_attempts", evidence.put_attempt_id), - ("artifact_verification_receipts", evidence.verification_receipt_id), - ): - await self._session.execute( - text(f"insert into {table_name}(id) values (:id) on conflict do nothing"), - {"id": value}, - ) - - async def require_request(self, attempt, evidence): - await self._ports.require_request(attempt, evidence) - await self._artifact(evidence) - - async def require_response(self, attempt_id, evidence): - await self._ports.require_response(attempt_id, evidence) - await self._artifact(evidence) - - def service(session: AsyncSession, ports: BoundaryPorts) -> ModelCallService: boundaries = DatabaseBoundaryPorts(session, ports) return ModelCallService( diff --git a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py index 6954c9053..84790265a 100644 --- a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py @@ -15,6 +15,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.identifiers import new_record_id +from app.modules.authorization.domain.model_usage import ( + GRANT, + ModelCallGrantAuthorityFacts, + checker_model_call_resource, +) +from app.modules.authorization.domain.resource_digest import authorization_resource_digest from app.modules.model_usage.api import ( ModelActionEvidence, ModelCallExecutionFacts, @@ -27,8 +33,8 @@ DatabaseFacts, model_database as _ledger_database, ) +from tests.checkers_model_calls.model_usage_boundary_support import BoundaryPorts from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( - BoundaryPorts, execution, provision, service, @@ -88,20 +94,45 @@ async def authorize_grant( evidence_id=self._evidence_id or new_record_id(), facts_digest=model_action_digest("grant", facts), ) + actor_id = new_record_id() + resource = checker_model_call_resource( + GRANT, + ModelCallGrantAuthorityFacts( + execution=execution_facts, + slot=paid_slot, + external_request_digest=external_request_digest, + token_hash=token_hash, + ), + ) + await self._session.execute( + text( + "insert into actor_profiles(id,actor_kind,status,service_identity) " + "values (:id,'service','active','workstream.checker.post_submit')" + ), + {"id": actor_id}, + ) await self._session.execute( text( "insert into audit_events(id,event_domain,event_type,actor_ref_kind,actor_id," - "action_id,permission_id,denial_code,after_facts) values " + "action_id,permission_id,project_id,resource_type,resource_id," + "denial_code,after_facts) values " "(:id,'authority','SensitiveAuthorizationAllowed','actor_profile',:actor," - ":action,'checker.model_call.grant',null,cast(:after as jsonb)) " + ":action,'checker.model_call.grant',:project,:resource_type,:resource_id," + "null,cast(:after as jsonb)) " "on conflict(id) do nothing" ), { "id": evidence.evidence_id, - "actor": str(new_record_id()), + "actor": str(actor_id), "action": self._action, + "project": str(resource.scope_project_id), + "resource_type": resource.resource_type, + "resource_id": str(resource.resource_id), "after": json.dumps( - {"allowed": True, "resource_context_digest": sha("grant authority")} + { + "allowed": True, + "resource_context_digest": authorization_resource_digest(resource), + } ), }, ) diff --git a/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py index 054debfb0..c55ca04cf 100644 --- a/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py @@ -8,9 +8,9 @@ DatabaseFacts, model_database as _model_database, ) +from tests.checkers_model_calls.model_usage_boundary_support import BoundaryPorts from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( AsyncSession, - BoundaryPorts, DBAPIError, ModelCallBudgetUnavailable, ModelCallConflict, diff --git a/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py b/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py index b462a687d..d4d2da50e 100644 --- a/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py @@ -12,9 +12,11 @@ DatabaseFacts, model_database as _model_database, ) -from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( +from tests.checkers_model_calls.model_usage_boundary_support import ( BoundaryPorts, DatabaseBoundaryPorts, +) +from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( evidence, prepare_and_reserve, provision, diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index b3457fd7b..24c694573 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -234,6 +234,7 @@ def test_expanded_lane_families_keep_exact_semantic_owners() -> None: assert schema_static_contracts.isdisjoint(partitioned_modules) assert { "tests/test_alembic.py", + "tests/migrations/test_project_catalogue_read.py", "tests/migrations/test_contribution_export_snapshots.py", "tests/migrations/test_contribution_read_audit.py", "tests/test_database_reset.py", From 9c30f5b71cf8cdea0c44423c1f5824c370e3078a Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:22:35 +0100 Subject: [PATCH 074/115] fix(model-usage): retain exact transition authority facts --- backend/alembic/versions/0062_model_usage.py | 114 +++++++++++++----- backend/app/modules/model_usage/models.py | 2 + backend/app/modules/model_usage/service.py | 2 + .../model_usage_boundary_support.py | 36 ++++-- .../model_usage_pg_support.py | 2 +- ...test_model_invocation_grants_postgresql.py | 18 ++- ...est_model_terminal_authority_postgresql.py | 68 +++++++++++ 7 files changed, 192 insertions(+), 50 deletions(-) diff --git a/backend/alembic/versions/0062_model_usage.py b/backend/alembic/versions/0062_model_usage.py index 7f81d26e8..57ed5f3b8 100644 --- a/backend/alembic/versions/0062_model_usage.py +++ b/backend/alembic/versions/0062_model_usage.py @@ -217,9 +217,8 @@ def _upgrade_invocation_grants() -> None: authority.action_id IS DISTINCT FROM 'checker.model_call.grant' OR authority.permission_id IS DISTINCT FROM 'checker.model_call.grant' OR authority.project_id IS DISTINCT FROM NEW.project_id OR - authority.resource_type IS DISTINCT FROM 'checker_model_call' OR - authority.resource_id IS DISTINCT FROM - NEW.execution_facts->>'evaluation_request_id' OR + authority.resource_type IS NOT NULL OR + authority.resource_id IS NOT NULL OR authority.denial_code IS NOT NULL OR authority.after_facts::jsonb IS DISTINCT FROM pg_catalog.jsonb_build_object( 'allowed',true,'resource_context_digest',authority_resource_digest) OR @@ -1405,6 +1404,7 @@ def downgrade() -> None: parent_attempt_id uuid, request_sha256 varchar(71) NOT NULL, state varchar(32) NOT NULL, + prepared_execution_facts jsonb, prepared_authorization_evidence_id uuid NOT NULL, request_binding_id uuid, request_content_id uuid, @@ -1416,6 +1416,7 @@ def downgrade() -> None: budget_configuration_version integer, budget_configuration_digest varchar(71), reserved_nanodollars bigint, + dispatch_execution_facts jsonb, dispatch_authorization_evidence_id uuid, provider_response_id varchar(256), response_binding_id uuid, @@ -1697,6 +1698,46 @@ def downgrade() -> None: END IF; RETURN NEW; END $$ +""", + r""" +CREATE FUNCTION public.model_usage_checker_execution_matches( + facts jsonb, expected_project_id uuid, expected_task_id uuid, + expected_assignment_id uuid, expected_submission_id uuid, + expected_submission_version integer +) RETURNS boolean +LANGUAGE sql IMMUTABLE STRICT SET search_path=pg_catalog,public,pg_temp AS $$ + SELECT jsonb_typeof(facts)='object' AND + (SELECT count(*) FROM jsonb_object_keys(facts))=11 AND + facts ?& ARRAY[ + 'project_id','task_id','assignment_id','submission_id','submission_version', + 'evaluation_request_id','evaluation_generation','checker_run_id', + 'worker_lease_id','worker_lease_generation','worker_lease_expires_at'] AND + jsonb_typeof(facts->'submission_version')='number' AND + jsonb_typeof(facts->'evaluation_generation')='number' AND + jsonb_typeof(facts->'worker_lease_generation')='number' AND + facts->'submission_version'=to_jsonb((facts->>'submission_version')::integer) AND + facts->'evaluation_generation'=to_jsonb((facts->>'evaluation_generation')::integer) AND + facts->'worker_lease_generation'= + to_jsonb((facts->>'worker_lease_generation')::integer) AND + (facts->>'submission_version')::integer>0 AND + (facts->>'evaluation_generation')::integer>0 AND + (facts->>'worker_lease_generation')::integer>0 AND + (facts->>'project_id')::uuid::text=facts->>'project_id' AND + (facts->>'task_id')::uuid::text=facts->>'task_id' AND + (facts->>'assignment_id')::uuid::text=facts->>'assignment_id' AND + (facts->>'submission_id')::uuid::text=facts->>'submission_id' AND + (facts->>'evaluation_request_id')::uuid::text=facts->>'evaluation_request_id' AND + (facts->>'checker_run_id')::uuid::text=facts->>'checker_run_id' AND + (facts->>'worker_lease_id')::uuid::text=facts->>'worker_lease_id' AND + jsonb_typeof(facts->'worker_lease_expires_at')='string' AND + facts->>'worker_lease_expires_at' ~ '(Z|[+-][0-9]{2}:[0-9]{2})$' AND + isfinite((facts->>'worker_lease_expires_at')::timestamptz) AND + (facts->>'project_id')::uuid=expected_project_id AND + (facts->>'task_id')::uuid=expected_task_id AND + (facts->>'assignment_id')::uuid=expected_assignment_id AND + (facts->>'submission_id')::uuid=expected_submission_id AND + (facts->>'submission_version')::integer=expected_submission_version +$$ """, r""" CREATE FUNCTION public.guard_model_usage_budget_configuration() RETURNS trigger @@ -2079,12 +2120,7 @@ def downgrade() -> None: 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( 'action',prepare_action,'facts',phase_facts))); ELSIF NEW.attempt_number=1 THEN - SELECT grant_row.execution_facts INTO phase_execution - FROM public.checker_model_invocation_grants grant_row - WHERE grant_row.project_id=slot.project_id AND - grant_row.paid_slot=terminal_slot AND - grant_row.execution_facts->>'evaluation_request_id'=prepare_authority.resource_id - ORDER BY grant_row.issued_at DESC,grant_row.id DESC LIMIT 1; + phase_execution:=NEW.prepared_execution_facts; phase_facts:=pg_catalog.jsonb_build_object( 'execution',phase_execution,'slot',terminal_slot, 'request_sha256',NEW.request_sha256); @@ -2138,6 +2174,10 @@ def downgrade() -> None: phase_actor.id IS NULL OR phase_facts IS NULL OR (NEW.checker_slot_id IS NOT NULL AND NEW.attempt_number=1 AND phase_execution IS NULL) OR + (NEW.checker_slot_id IS NOT NULL AND NEW.attempt_number=1 AND + public.model_usage_checker_execution_matches( + phase_execution,slot.project_id,slot.task_id,slot.assignment_id, + slot.submission_id,slot.submission_version) IS NOT TRUE) OR (NEW.checker_slot_id IS NOT NULL AND NEW.attempt_number>1 AND (parent.id IS NULL OR retry_terminal_digest IS NULL)) OR prepare_authority.event_domain IS DISTINCT FROM 'authority' OR @@ -2147,8 +2187,10 @@ def downgrade() -> None: prepare_authority.permission_id IS DISTINCT FROM prepare_action OR prepare_authority.project_id IS DISTINCT FROM coalesce(slot.project_id,setup_slot.project_id) OR - prepare_authority.resource_type IS DISTINCT FROM phase_resource->>'resource_type' OR - prepare_authority.resource_id IS DISTINCT FROM phase_resource->>'resource_id' OR + prepare_authority.resource_type IS DISTINCT FROM + CASE WHEN NEW.attempt_number>1 THEN 'project' ELSE NULL END OR + prepare_authority.resource_id IS DISTINCT FROM + CASE WHEN NEW.attempt_number>1 THEN slot.project_id::text ELSE NULL END OR prepare_authority.denial_code IS NOT NULL OR prepare_authority.after_facts::jsonb IS DISTINCT FROM pg_catalog.jsonb_build_object( @@ -2243,7 +2285,9 @@ def downgrade() -> None: END IF; IF TG_OP='INSERT' THEN IF NEW.attempt_number=1 THEN - IF NEW.parent_attempt_id IS NOT NULL THEN + IF NEW.parent_attempt_id IS NOT NULL OR + (NEW.checker_slot_id IS NOT NULL AND NEW.prepared_execution_facts IS NULL) OR + (NEW.setup_slot_id IS NOT NULL AND NEW.prepared_execution_facts IS NOT NULL) THEN RAISE EXCEPTION 'initial checker model attempt cannot have a parent' USING ERRCODE='23514'; END IF; ELSE @@ -2257,6 +2301,7 @@ def downgrade() -> None: FROM public.model_usage_effects WHERE checker_slot_id IS NOT DISTINCT FROM NEW.checker_slot_id AND setup_slot_id IS NOT DISTINCT FROM NEW.setup_slot_id) OR + NEW.prepared_execution_facts IS NOT NULL OR NOT (parent.state='known_no_effect' OR (parent.state='known_failed' AND parent.settled_nanodollars IS NOT NULL AND parent.settlement_authorization_evidence_id IS NOT NULL)) THEN @@ -2266,11 +2311,13 @@ def downgrade() -> None: ELSIF ROW(NEW.checker_slot_id,NEW.setup_slot_id,NEW.attempt_number, NEW.internal_call_request_id, NEW.parent_attempt_id,NEW.request_sha256, + NEW.prepared_execution_facts, NEW.prepared_authorization_evidence_id,NEW.prepared_at) IS DISTINCT FROM ROW(OLD.checker_slot_id,OLD.setup_slot_id,OLD.attempt_number, OLD.internal_call_request_id, OLD.parent_attempt_id,OLD.request_sha256, + OLD.prepared_execution_facts, OLD.prepared_authorization_evidence_id,OLD.prepared_at) THEN RAISE EXCEPTION 'checker model call attempt identity is immutable' USING ERRCODE='55000'; END IF; @@ -2279,14 +2326,16 @@ def downgrade() -> None: NEW.request_verification_receipt_id,NEW.request_content_sha256, NEW.request_byte_count,NEW.budget_configuration_id, NEW.budget_configuration_version,NEW.budget_configuration_digest, - NEW.reserved_nanodollars,NEW.dispatch_authorization_evidence_id, + NEW.reserved_nanodollars,NEW.dispatch_execution_facts, + NEW.dispatch_authorization_evidence_id, NEW.dispatched_at) IS DISTINCT FROM ROW(OLD.request_binding_id,OLD.request_content_id,OLD.request_put_attempt_id, OLD.request_verification_receipt_id,OLD.request_content_sha256, OLD.request_byte_count,OLD.budget_configuration_id, OLD.budget_configuration_version,OLD.budget_configuration_digest, - OLD.reserved_nanodollars,OLD.dispatch_authorization_evidence_id, + OLD.reserved_nanodollars,OLD.dispatch_execution_facts, + OLD.dispatch_authorization_evidence_id, OLD.dispatched_at) THEN RAISE EXCEPTION 'checker model dispatch facts are immutable' USING ERRCODE='55000'; END IF; @@ -2299,6 +2348,7 @@ def downgrade() -> None: NEW.budget_configuration_version IS NOT NULL OR NEW.budget_configuration_digest IS NOT NULL OR NEW.reserved_nanodollars IS NOT NULL OR + NEW.dispatch_execution_facts IS NOT NULL OR NEW.dispatch_authorization_evidence_id IS NOT NULL OR NEW.dispatched_at IS NOT NULL OR NEW.provider_response_id IS NOT NULL OR NEW.response_binding_id IS NOT NULL OR NEW.response_content_id IS NOT NULL OR @@ -2339,14 +2389,7 @@ def downgrade() -> None: 'attempt_number',NEW.attempt_number, 'internal_call_request_id',NEW.internal_call_request_id, 'request_sha256',NEW.request_sha256,'state','prepared','replayed',false); - SELECT grant_row.execution_facts INTO phase_execution - FROM public.checker_model_invocation_grant_calls grant_call - JOIN public.checker_model_invocation_grants grant_row - ON grant_row.id=grant_call.grant_id - WHERE grant_call.attempt_id=NEW.id AND - grant_call.request_sha256=NEW.request_sha256 AND - grant_row.issued_at<=dispatch_authority.occurred_at - ORDER BY grant_row.issued_at DESC,grant_row.id DESC LIMIT 1; + phase_execution:=NEW.dispatch_execution_facts; phase_facts:=pg_catalog.jsonb_build_object( 'execution',phase_execution,'attempt',phase_attempt, 'request_evidence',phase_request_evidence); @@ -2371,6 +2414,11 @@ def downgrade() -> None: IF dispatch_authority.id IS NULL OR phase_actor.id IS NULL OR phase_facts IS NULL OR (NEW.checker_slot_id IS NOT NULL AND phase_execution IS NULL) OR + (NEW.checker_slot_id IS NOT NULL AND + public.model_usage_checker_execution_matches( + phase_execution,slot.project_id,slot.task_id,slot.assignment_id, + slot.submission_id,slot.submission_version) IS NOT TRUE) OR + (NEW.setup_slot_id IS NOT NULL AND NEW.dispatch_execution_facts IS NOT NULL) OR dispatch_authority.event_domain IS DISTINCT FROM 'authority' OR dispatch_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR dispatch_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR @@ -2378,8 +2426,8 @@ def downgrade() -> None: dispatch_authority.permission_id IS DISTINCT FROM dispatch_action OR dispatch_authority.project_id IS DISTINCT FROM coalesce(slot.project_id,setup_slot.project_id) OR - dispatch_authority.resource_type IS DISTINCT FROM terminal_resource_type OR - dispatch_authority.resource_id IS DISTINCT FROM NEW.id::text OR + dispatch_authority.resource_type IS NOT NULL OR + dispatch_authority.resource_id IS NOT NULL OR dispatch_authority.denial_code IS NOT NULL OR dispatch_authority.after_facts::jsonb IS DISTINCT FROM pg_catalog.jsonb_build_object( @@ -2483,8 +2531,8 @@ def downgrade() -> None: observation_authority.permission_id IS DISTINCT FROM observation_action OR observation_authority.project_id IS DISTINCT FROM coalesce(slot.project_id,setup_slot.project_id) OR - observation_authority.resource_type IS DISTINCT FROM terminal_resource_type OR - observation_authority.resource_id IS DISTINCT FROM NEW.id::text OR + observation_authority.resource_type IS NOT NULL OR + observation_authority.resource_id IS NOT NULL OR observation_authority.denial_code IS NOT NULL OR observation_authority.after_facts::jsonb IS DISTINCT FROM pg_catalog.jsonb_build_object( @@ -2521,8 +2569,8 @@ def downgrade() -> None: terminal_authority.permission_id IS DISTINCT FROM terminal_action OR terminal_authority.project_id IS DISTINCT FROM coalesce(slot.project_id,setup_slot.project_id) OR - terminal_authority.resource_type IS DISTINCT FROM terminal_resource_type OR - terminal_authority.resource_id IS DISTINCT FROM NEW.id::text OR + terminal_authority.resource_type IS NOT NULL OR + terminal_authority.resource_id IS NOT NULL OR terminal_authority.denial_code IS NOT NULL OR terminal_authority.after_facts::jsonb IS DISTINCT FROM pg_catalog.jsonb_build_object( @@ -2551,8 +2599,8 @@ def downgrade() -> None: terminal_authority.permission_id IS DISTINCT FROM terminal_action OR terminal_authority.project_id IS DISTINCT FROM coalesce(slot.project_id,setup_slot.project_id) OR - terminal_authority.resource_type IS DISTINCT FROM terminal_resource_type OR - terminal_authority.resource_id IS DISTINCT FROM NEW.id::text OR + terminal_authority.resource_type IS NOT NULL OR + terminal_authority.resource_id IS NOT NULL OR terminal_authority.denial_code IS NOT NULL OR terminal_authority.after_facts::jsonb IS DISTINCT FROM pg_catalog.jsonb_build_object( @@ -2582,8 +2630,8 @@ def downgrade() -> None: terminal_authority.permission_id IS DISTINCT FROM terminal_action OR terminal_authority.project_id IS DISTINCT FROM coalesce(slot.project_id,setup_slot.project_id) OR - terminal_authority.resource_type IS DISTINCT FROM terminal_resource_type OR - terminal_authority.resource_id IS DISTINCT FROM NEW.id::text OR + terminal_authority.resource_type IS NOT NULL OR + terminal_authority.resource_id IS NOT NULL OR terminal_authority.denial_code IS NOT NULL OR terminal_authority.after_facts::jsonb IS DISTINCT FROM pg_catalog.jsonb_build_object( @@ -2746,6 +2794,8 @@ def downgrade() -> None: authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR authority.action_id IS DISTINCT FROM 'checker.model_call.recover' OR authority.permission_id IS DISTINCT FROM 'checker.model_call.recover' OR + authority.project_id IS DISTINCT FROM slot.project_id OR + authority.resource_type IS NOT NULL OR authority.resource_id IS NOT NULL OR authority.denial_code IS NOT NULL OR authority.after_facts::jsonb IS DISTINCT FROM jsonb_build_object( 'allowed',true,'resource_context_digest',expected_digest) OR diff --git a/backend/app/modules/model_usage/models.py b/backend/app/modules/model_usage/models.py index 88c5f64cf..a0a902a12 100644 --- a/backend/app/modules/model_usage/models.py +++ b/backend/app/modules/model_usage/models.py @@ -614,6 +614,7 @@ class ModelUsageEffect(Base): ) request_sha256: Mapped[str] = mapped_column(String(71), nullable=False) state: Mapped[str] = mapped_column(String(32), nullable=False) + prepared_execution_facts: Mapped[dict | None] = mapped_column(JSONB) prepared_authorization_evidence_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), nullable=False ) @@ -636,6 +637,7 @@ class ModelUsageEffect(Base): budget_configuration_version: Mapped[int | None] = mapped_column(Integer) budget_configuration_digest: Mapped[str | None] = mapped_column(String(71)) reserved_nanodollars: Mapped[int | None] = mapped_column(BigInteger) + dispatch_execution_facts: Mapped[dict | None] = mapped_column(JSONB) dispatch_authorization_evidence_id: Mapped[str | None] = mapped_column( Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), unique=True ) diff --git a/backend/app/modules/model_usage/service.py b/backend/app/modules/model_usage/service.py index 86e5757cc..8e29cdb20 100644 --- a/backend/app/modules/model_usage/service.py +++ b/backend/app/modules/model_usage/service.py @@ -561,6 +561,7 @@ async def prepare_attempt( internal_call_request_id=str(new_record_id()), request_sha256=request_sha256, state="prepared", + prepared_execution_facts=execution.model_dump(mode="json"), prepared_authorization_evidence_id=str(authority.evidence_id), ) await self._repo.add(attempt) @@ -740,6 +741,7 @@ async def reserve_dispatch( row.budget_configuration_version = budget.configuration_version row.budget_configuration_digest = budget.configuration_digest row.reserved_nanodollars = reservation + row.dispatch_execution_facts = execution.model_dump(mode="json") row.dispatch_authorization_evidence_id = str(authority.evidence_id) row.dispatched_at = await self._repo.now() row.state = "dispatching" diff --git a/backend/tests/checkers_model_calls/model_usage_boundary_support.py b/backend/tests/checkers_model_calls/model_usage_boundary_support.py index 2b356f73f..6a8a229ca 100644 --- a/backend/tests/checkers_model_calls/model_usage_boundary_support.py +++ b/backend/tests/checkers_model_calls/model_usage_boundary_support.py @@ -38,6 +38,7 @@ project_model_admin_resource, setup_model_usage_resource, ) +from app.modules.authorization.domain.audit_targets import project_authority_audit_target from app.modules.authorization.domain.resource_digest import authorization_resource_digest from app.modules.model_usage.api import ( ModelActionEvidence, @@ -61,6 +62,7 @@ class BoundaryPorts: current_checks: int = 0 events: list[str] = field(default_factory=list) corrupt_admin_receipt: bool = False + corrupt_runtime_receipt_action: str | None = None terminal_receipt_change: str | None = None def _decision(self, action: str, facts: object) -> ModelActionEvidence: @@ -241,10 +243,15 @@ async def _authority( project_id = getattr(resource, "scope_project_id", None) or getattr( resource, "project_id", None ) - resource_type = resource.resource_type - resource_id = resource.resource_id if resource_digest is None: resource_digest = authorization_resource_digest(resource) + target = project_authority_audit_target( + resource, + resource.action_id, + matched_scope_project_id=project_id, + ) + if target is not None: + project_id, resource_type, resource_id, _, _ = target await self._session.execute( text( "insert into audit_events(id,event_domain,event_type,actor_ref_kind,actor_id," @@ -267,7 +274,10 @@ async def _authority( "allowed": True, "resource_context_digest": ( sha("substituted-authority") - if actor_id is not None and self._ports.corrupt_admin_receipt + if ( + actor_id is not None and self._ports.corrupt_admin_receipt + ) + or self._ports.corrupt_runtime_receipt_action == action else resource_digest or sha("authority") ), } @@ -278,30 +288,30 @@ async def _authority( async def authorize_profile(self, facts): evidence = await self._ports.authorize_profile(facts) + resource = project_model_admin_resource( + PROFILE_PROVISION, facts, actor_profile_id=facts.actor_profile_id + ) return await self._authority( evidence, action="project.model_profile.provision", permission="project.model_profile.manage", actor_id=facts.actor_profile_id, - resource_digest=authorization_resource_digest( - project_model_admin_resource( - PROFILE_PROVISION, facts, actor_profile_id=facts.actor_profile_id - ) - ), + resource=resource, + resource_digest=authorization_resource_digest(resource), ) async def authorize_budget(self, facts): evidence = await self._ports.authorize_budget(facts) + resource = project_model_admin_resource( + BUDGET_CONFIGURE, facts, actor_profile_id=facts.actor_profile_id + ) return await self._authority( evidence, action="project.model_budget.configure", permission="project.model_budget.manage", actor_id=facts.actor_profile_id, - resource_digest=authorization_resource_digest( - project_model_admin_resource( - BUDGET_CONFIGURE, facts, actor_profile_id=facts.actor_profile_id - ) - ), + resource=resource, + resource_digest=authorization_resource_digest(resource), ) async def authorize_prepare(self, execution, slot, request_sha256): diff --git a/backend/tests/checkers_model_calls/model_usage_pg_support.py b/backend/tests/checkers_model_calls/model_usage_pg_support.py index db243a90e..015e7e44e 100644 --- a/backend/tests/checkers_model_calls/model_usage_pg_support.py +++ b/backend/tests/checkers_model_calls/model_usage_pg_support.py @@ -65,7 +65,7 @@ def isolated(statement: str) -> str: "create table external_checker_registry_entries(id uuid primary key,entry_digest text not null,phase text not null)", "create table checker_runs(id uuid primary key,task_id uuid,submission_id uuid,submission_version integer,project_id uuid,evaluation_request_id uuid,phase text,evaluation_generation integer,request_json text,worker_lease_id uuid,worker_lease_generation integer,worker_lease_expires_at timestamptz,status text,selected_post_submit_policy_sha256 text,selected_post_submit_policy_body jsonb,locked_post_submit_checker_policy_hash text)", "create table actor_profiles(id uuid primary key,actor_kind text default 'human',status text default 'active',service_identity text)", - "create table audit_events(id uuid primary key, event_domain text, event_type text, actor_ref_kind text, actor_id text, action_id text, permission_id text, denial_code text, after_facts json)", + "create table audit_events(id uuid primary key, event_domain text, event_type text, actor_ref_kind text, actor_id text, action_id text, permission_id text, project_id uuid, resource_type text, resource_id text, matched_grant_id uuid, denial_code text, after_facts json)", "create table artifact_bindings(id uuid primary key)", "create table artifact_contents(id uuid primary key)", "create table artifact_put_attempts(id uuid primary key)", diff --git a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py index 84790265a..83a20513a 100644 --- a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py @@ -72,10 +72,12 @@ def __init__( *, evidence_id: UUID | None = None, action: str = "checker.model_call.grant", + corrupt_digest: bool = False, ) -> None: self._session = session self._evidence_id = evidence_id self._action = action + self._corrupt_digest = corrupt_digest async def authorize_grant( self, @@ -117,7 +119,7 @@ async def authorize_grant( "action_id,permission_id,project_id,resource_type,resource_id," "denial_code,after_facts) values " "(:id,'authority','SensitiveAuthorizationAllowed','actor_profile',:actor," - ":action,'checker.model_call.grant',:project,:resource_type,:resource_id," + ":action,'checker.model_call.grant',:project,null,null," "null,cast(:after as jsonb)) " "on conflict(id) do nothing" ), @@ -126,12 +128,14 @@ async def authorize_grant( "actor": str(actor_id), "action": self._action, "project": str(resource.scope_project_id), - "resource_type": resource.resource_type, - "resource_id": str(resource.resource_id), "after": json.dumps( { "allowed": True, - "resource_context_digest": authorization_resource_digest(resource), + "resource_context_digest": ( + sha("foreign grant authority") + if self._corrupt_digest + else authorization_resource_digest(resource) + ), } ), }, @@ -471,6 +475,12 @@ async def test_grant_rejects_reused_or_wrong_authority_and_stale_db_clock( boundaries = GrantBoundaries(session, action="checker.model_call.dispatch") await _issue(grant_database, boundaries=boundaries, session=session) + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session, corrupt_digest=True) + await _issue(grant_database, boundaries=boundaries, session=session) + expired = grant_database.execution.model_copy( update={"worker_lease_expires_at": datetime.now(UTC) - timedelta(seconds=1)} ) diff --git a/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py b/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py index d4d2da50e..e00cdad73 100644 --- a/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py @@ -18,17 +18,85 @@ ) from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( evidence, + execution, prepare_and_reserve, provision, service, setup_service, setup_slot_key, sha, + slot, ) model_database = _model_database +@pytest.mark.parametrize( + "action", + ( + "checker.model_call.prepare", + "checker.model_call.dispatch", + "checker.model_call.observe", + ), +) +async def test_runtime_transition_rejects_foreign_authority_digest( + model_database: DatabaseFacts, + action: str, +) -> None: + """Each paid transition binds the exact private AUTH resource, not digest shape.""" + ports = BoundaryPorts() + profile = await provision(model_database, ports, limit=10_000) + run = execution(model_database) + paid_slot = slot(model_database, profile, action.rsplit(".", 1)[-1]) + request_sha = sha("authority-" + action) + request_art = evidence("authority-" + action, request_sha) + + if action == "checker.model_call.prepare": + ports.corrupt_runtime_receipt_action = action + with pytest.raises(DBAPIError, match="model usage prepare authority is invalid"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await service(session, ports).prepare_attempt( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=request_sha, + ) + return + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + prepared = await service(session, ports).prepare_attempt( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=request_sha, + ) + ports.corrupt_runtime_receipt_action = action + if action == "checker.model_call.dispatch": + with pytest.raises(DBAPIError, match="invalid dispatched checker model call"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await service(session, ports).reserve_dispatch(run, prepared, request_art) + return + + ports.corrupt_runtime_receipt_action = None + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + dispatched = await service(session, ports).reserve_dispatch( + run, prepared, request_art + ) + ports.corrupt_runtime_receipt_action = action + with pytest.raises(DBAPIError, match="invalid uncertain checker model call"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await service(session, ports).mark_uncertain( + dispatched.effect, + provider_response_id=None, + observation_digest=sha("authority-observation"), + ) + + async def test_known_failed_settles_once_and_allows_authorized_child( model_database: DatabaseFacts, ) -> None: From 9b4779dba4677ecbf2df09f867645fa9307d89e9 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:24:57 +0100 Subject: [PATCH 075/115] fix(model-usage): match private AUTH audit projection --- backend/alembic/versions/0062_model_usage.py | 21 +++++++------------ .../model_usage_boundary_support.py | 8 +++++-- ...test_model_invocation_grants_postgresql.py | 3 +-- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/backend/alembic/versions/0062_model_usage.py b/backend/alembic/versions/0062_model_usage.py index 57ed5f3b8..f9b40b057 100644 --- a/backend/alembic/versions/0062_model_usage.py +++ b/backend/alembic/versions/0062_model_usage.py @@ -216,7 +216,7 @@ def _upgrade_invocation_grants() -> None: authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR authority.action_id IS DISTINCT FROM 'checker.model_call.grant' OR authority.permission_id IS DISTINCT FROM 'checker.model_call.grant' OR - authority.project_id IS DISTINCT FROM NEW.project_id OR + authority.project_id IS NOT NULL OR authority.resource_type IS NOT NULL OR authority.resource_id IS NOT NULL OR authority.denial_code IS NOT NULL OR @@ -2186,7 +2186,7 @@ def downgrade() -> None: prepare_authority.action_id IS DISTINCT FROM prepare_action OR prepare_authority.permission_id IS DISTINCT FROM prepare_action OR prepare_authority.project_id IS DISTINCT FROM - coalesce(slot.project_id,setup_slot.project_id) OR + CASE WHEN NEW.attempt_number>1 THEN slot.project_id ELSE NULL END OR prepare_authority.resource_type IS DISTINCT FROM CASE WHEN NEW.attempt_number>1 THEN 'project' ELSE NULL END OR prepare_authority.resource_id IS DISTINCT FROM @@ -2424,8 +2424,7 @@ def downgrade() -> None: dispatch_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR dispatch_authority.action_id IS DISTINCT FROM dispatch_action OR dispatch_authority.permission_id IS DISTINCT FROM dispatch_action OR - dispatch_authority.project_id IS DISTINCT FROM - coalesce(slot.project_id,setup_slot.project_id) OR + dispatch_authority.project_id IS NOT NULL OR dispatch_authority.resource_type IS NOT NULL OR dispatch_authority.resource_id IS NOT NULL OR dispatch_authority.denial_code IS NOT NULL OR @@ -2529,8 +2528,7 @@ def downgrade() -> None: observation_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR observation_authority.action_id IS DISTINCT FROM observation_action OR observation_authority.permission_id IS DISTINCT FROM observation_action OR - observation_authority.project_id IS DISTINCT FROM - coalesce(slot.project_id,setup_slot.project_id) OR + observation_authority.project_id IS NOT NULL OR observation_authority.resource_type IS NOT NULL OR observation_authority.resource_id IS NOT NULL OR observation_authority.denial_code IS NOT NULL OR @@ -2567,8 +2565,7 @@ def downgrade() -> None: terminal_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR terminal_authority.action_id IS DISTINCT FROM terminal_action OR terminal_authority.permission_id IS DISTINCT FROM terminal_action OR - terminal_authority.project_id IS DISTINCT FROM - coalesce(slot.project_id,setup_slot.project_id) OR + terminal_authority.project_id IS NOT NULL OR terminal_authority.resource_type IS NOT NULL OR terminal_authority.resource_id IS NOT NULL OR terminal_authority.denial_code IS NOT NULL OR @@ -2597,8 +2594,7 @@ def downgrade() -> None: terminal_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR terminal_authority.action_id IS DISTINCT FROM terminal_action OR terminal_authority.permission_id IS DISTINCT FROM terminal_action OR - terminal_authority.project_id IS DISTINCT FROM - coalesce(slot.project_id,setup_slot.project_id) OR + terminal_authority.project_id IS NOT NULL OR terminal_authority.resource_type IS NOT NULL OR terminal_authority.resource_id IS NOT NULL OR terminal_authority.denial_code IS NOT NULL OR @@ -2628,8 +2624,7 @@ def downgrade() -> None: terminal_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR terminal_authority.action_id IS DISTINCT FROM terminal_action OR terminal_authority.permission_id IS DISTINCT FROM terminal_action OR - terminal_authority.project_id IS DISTINCT FROM - coalesce(slot.project_id,setup_slot.project_id) OR + terminal_authority.project_id IS NOT NULL OR terminal_authority.resource_type IS NOT NULL OR terminal_authority.resource_id IS NOT NULL OR terminal_authority.denial_code IS NOT NULL OR @@ -2794,7 +2789,7 @@ def downgrade() -> None: authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR authority.action_id IS DISTINCT FROM 'checker.model_call.recover' OR authority.permission_id IS DISTINCT FROM 'checker.model_call.recover' OR - authority.project_id IS DISTINCT FROM slot.project_id OR + authority.project_id IS NOT NULL OR authority.resource_type IS NOT NULL OR authority.resource_id IS NOT NULL OR authority.denial_code IS NOT NULL OR authority.after_facts::jsonb IS DISTINCT FROM jsonb_build_object( diff --git a/backend/tests/checkers_model_calls/model_usage_boundary_support.py b/backend/tests/checkers_model_calls/model_usage_boundary_support.py index 6a8a229ca..897345f6e 100644 --- a/backend/tests/checkers_model_calls/model_usage_boundary_support.py +++ b/backend/tests/checkers_model_calls/model_usage_boundary_support.py @@ -240,7 +240,7 @@ async def _authority( resource_type = None resource_id = None if resource is not None: - project_id = getattr(resource, "scope_project_id", None) or getattr( + resource_project_id = getattr(resource, "scope_project_id", None) or getattr( resource, "project_id", None ) if resource_digest is None: @@ -248,7 +248,11 @@ async def _authority( target = project_authority_audit_target( resource, resource.action_id, - matched_scope_project_id=project_id, + matched_scope_project_id=( + resource_project_id + if resource.resource_type == "project_model_administration" + else None + ), ) if target is not None: project_id, resource_type, resource_id, _, _ = target diff --git a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py index 83a20513a..6e5664a06 100644 --- a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py @@ -119,7 +119,7 @@ async def authorize_grant( "action_id,permission_id,project_id,resource_type,resource_id," "denial_code,after_facts) values " "(:id,'authority','SensitiveAuthorizationAllowed','actor_profile',:actor," - ":action,'checker.model_call.grant',:project,null,null," + ":action,'checker.model_call.grant',null,null,null," "null,cast(:after as jsonb)) " "on conflict(id) do nothing" ), @@ -127,7 +127,6 @@ async def authorize_grant( "id": evidence.evidence_id, "actor": str(actor_id), "action": self._action, - "project": str(resource.scope_project_id), "after": json.dumps( { "allowed": True, From 3ab426cdd29a38aa1dc73d1c6a1627fb1fee1f84 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:26:51 +0100 Subject: [PATCH 076/115] test(model-usage): hold response before concurrent recovery --- .../test_composed_execution.py | 64 ++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/backend/tests/checkers_model_calls/test_composed_execution.py b/backend/tests/checkers_model_calls/test_composed_execution.py index 9e00606b5..9bef93ede 100644 --- a/backend/tests/checkers_model_calls/test_composed_execution.py +++ b/backend/tests/checkers_model_calls/test_composed_execution.py @@ -194,7 +194,9 @@ async def publish(_factory, project_id, registry): await provision_post_submit_service(h.factory, ServiceIdentity.ARTIFACT_CHECKER_OUTPUT) creates = [] lost_replies = [] - provider_accepted = asyncio.Event() + recovered_references = [] + first_response_waiting = asyncio.Event() + release_first_response = asyncio.Event() class Provider: async def create(self, request): @@ -206,7 +208,6 @@ async def create(self, request): ) failed = terminal_mode == "failed" and len(creates) == 1 reference = CheckerModelResponseReference(f"resp_composed_{len(creates)}") - provider_accepted.set() return CheckerModelCreateResult( CheckerModelCreateOutcome.ACCEPTED, known_response=reference, @@ -226,6 +227,10 @@ async def recover(self, reference): # persisted its provider identity but before response ART is # terminal. It may retrieve that exact effect, never create it # again; completed replays still read retained ART directly. + recovered_references.append(reference.provider_response_id) + if concurrent_callback: + assert first_response_waiting.is_set() + assert not release_first_response.is_set() return CheckerModelRecoveryResult( CheckerModelRecoveryOutcome.OBSERVED, reference, @@ -242,19 +247,33 @@ async def recover(self, reference): async def provider(_): yield Provider() + outputs = checker_output_storage( + sessions=h.factory, + store=h.store.wrapped, + namespace=h.namespace, + preparation=h.preparation, + settings=h.settings, + ) + original_store = outputs.store + + async def hold_first_response(request): + if ( + concurrent_callback + and request.selector.slot_key == "model_response" + and not first_response_waiting.is_set() + ): + first_response_waiting.set() + await asyncio.wait_for(release_first_response.wait(), timeout=30) + return await original_store(request) + + monkeypatch.setattr(outputs, "store", hold_first_response) proxy = CheckerModelProxy( sessions=h.factory, authorization=checker_model_service_authorization, grants=model_invocation_grants, calls=model_call_service, profiles=model_profile_reader, - outputs=checker_output_storage( - sessions=h.factory, - store=h.store.wrapped, - namespace=h.namespace, - preparation=h.preparation, - settings=h.settings, - ), + outputs=outputs, bindings=lambda session: checker_output_binding(session, namespace=h.namespace), provider=provider, ) @@ -277,20 +296,21 @@ async def execute(self, request, grant, *, model_grant=None): ) if concurrent_callback: first = asyncio.create_task(proxy.handle(callback)) - await asyncio.wait_for(provider_accepted.wait(), timeout=5) - for _ in range(500): + try: + await asyncio.wait_for(first_response_waiting.wait(), timeout=30) async with h.factory() as session: - known = await session.scalar( - select(ModelUsageEffect.id).where( - ModelUsageEffect.provider_response_id.is_not(None) - ) - ) - if known is not None: - break - await asyncio.sleep(0.01) - assert known is not None - second = asyncio.create_task(proxy.handle(callback)) - replies = await asyncio.gather(first, second) + pending = await session.scalar(select(ModelUsageEffect)) + assert pending.provider_response_id == "resp_composed_1" + assert pending.state == "uncertain" + assert pending.response_binding_id is None + # The first callback cannot settle while the second retrieves + # the known response. A cached terminal replay cannot pass. + second_reply = await proxy.handle(callback) + assert recovered_references == ["resp_composed_1"] + finally: + release_first_response.set() + first_reply = await first + replies = [first_reply, second_reply] assert [reply.outcome for reply in replies] == ["completed", "completed"] assert replies[0].receipt_digest == replies[1].receipt_digest reply = replies[0] From 5b03bbf7478a12ca6f8b64fbee487589d8aea22f Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:27:37 +0100 Subject: [PATCH 077/115] fix(model-usage): parse projected recovery authority --- backend/alembic/versions/0062_model_usage.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/alembic/versions/0062_model_usage.py b/backend/alembic/versions/0062_model_usage.py index f9b40b057..fb44aeec6 100644 --- a/backend/alembic/versions/0062_model_usage.py +++ b/backend/alembic/versions/0062_model_usage.py @@ -2185,12 +2185,14 @@ def downgrade() -> None: prepare_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR prepare_authority.action_id IS DISTINCT FROM prepare_action OR prepare_authority.permission_id IS DISTINCT FROM prepare_action OR - prepare_authority.project_id IS DISTINCT FROM - CASE WHEN NEW.attempt_number>1 THEN slot.project_id ELSE NULL END OR - prepare_authority.resource_type IS DISTINCT FROM - CASE WHEN NEW.attempt_number>1 THEN 'project' ELSE NULL END OR - prepare_authority.resource_id IS DISTINCT FROM - CASE WHEN NEW.attempt_number>1 THEN slot.project_id::text ELSE NULL END OR + (NEW.attempt_number=1 AND + (prepare_authority.project_id IS NOT NULL OR + prepare_authority.resource_type IS NOT NULL OR + prepare_authority.resource_id IS NOT NULL)) OR + (NEW.attempt_number>1 AND + (prepare_authority.project_id IS DISTINCT FROM slot.project_id OR + prepare_authority.resource_type IS DISTINCT FROM 'project' OR + prepare_authority.resource_id IS DISTINCT FROM slot.project_id::text)) OR prepare_authority.denial_code IS NOT NULL OR prepare_authority.after_facts::jsonb IS DISTINCT FROM pg_catalog.jsonb_build_object( From 1103b2d1eb9c0c031fdeb272971fea47d42f7117 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:28:56 +0100 Subject: [PATCH 078/115] test(model-usage): reject crossed setup transition receipts --- ...est_model_terminal_authority_postgresql.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py b/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py index e00cdad73..9a33a18e7 100644 --- a/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py @@ -97,6 +97,61 @@ async def test_runtime_transition_rejects_foreign_authority_digest( ) +@pytest.mark.parametrize( + "action", + ( + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.observe", + ), +) +async def test_setup_transition_rejects_foreign_authority_digest( + model_database: DatabaseFacts, + action: str, +) -> None: + """Setup transitions independently bind their exact private AUTH resource.""" + ports = BoundaryPorts() + profile = await provision(model_database, ports, limit=10_000) + key = await setup_slot_key(model_database, profile) + request_sha = sha("setup-authority-" + action) + request_art = evidence("setup-authority-" + action, request_sha) + + if action == "project.guide_compilation.model.prepare": + ports.corrupt_runtime_receipt_action = action + with pytest.raises(DBAPIError, match="model usage prepare authority is invalid"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await setup_service(session, ports).prepare(key, request_sha) + return + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + prepared = await setup_service(session, ports).prepare(key, request_sha) + ports.corrupt_runtime_receipt_action = action + if action == "project.guide_compilation.model.dispatch": + with pytest.raises(DBAPIError, match="invalid dispatched checker model call"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await setup_service(session, ports).reserve_dispatch(prepared, request_art) + return + + ports.corrupt_runtime_receipt_action = None + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + dispatched = await setup_service(session, ports).reserve_dispatch( + prepared, request_art + ) + ports.corrupt_runtime_receipt_action = action + with pytest.raises(DBAPIError, match="invalid uncertain checker model call"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await setup_service(session, ports).mark_uncertain( + dispatched.effect, + sha("setup-authority-observation"), + provider_response_id=None, + ) + + async def test_known_failed_settles_once_and_allows_authorized_child( model_database: DatabaseFacts, ) -> None: From 13eacce220f2eac4ceb997460968be5a42518aac Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:30:01 +0100 Subject: [PATCH 079/115] test(model-usage): settle after renewed invocation grant --- ...test_model_invocation_grants_postgresql.py | 74 ++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py index 6e5664a06..1c19bf0e3 100644 --- a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py @@ -24,17 +24,21 @@ from app.modules.model_usage.api import ( ModelActionEvidence, ModelCallExecutionFacts, + ModelCallUsage, ModelCallUnavailable, ModelPaidSlotKey, + PreparedModelCallAttempt, model_action_digest, ) from app.modules.model_usage.grants import ModelInvocationGrants +from app.modules.model_usage.models import ModelUsageBudgetAccount, ModelUsageEffect from tests.checkers_model_calls.model_usage_pg_support import ( DatabaseFacts, model_database as _ledger_database, ) from tests.checkers_model_calls.model_usage_boundary_support import BoundaryPorts from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( + evidence, execution, provision, service, @@ -59,6 +63,7 @@ class GrantDatabase: facts: DatabaseFacts execution: ModelCallExecutionFacts slot: ModelPaidSlotKey + prepared: PreparedModelCallAttempt prepared_attempt_id: UUID prepared_request_sha256: str @@ -247,6 +252,7 @@ async def grant_database(ledger_database_fixture: DatabaseFacts) -> GrantDatabas ledger_database, current, paid_slot, + prepared, prepared.attempt_id, request_sha256, ) @@ -375,7 +381,37 @@ async def test_renewed_and_successor_grants_reattach_the_same_paid_effect( request_sha256=grant_database.prepared_request_sha256, prepared_attempt_id=grant_database.prepared_attempt_id, ) - renewed = await _issue(grant_database, boundaries=boundaries, session=session) + renewed_execution = grant_database.execution.model_copy( + update={ + "worker_lease_id": new_record_id(), + "worker_lease_generation": ( + grant_database.execution.worker_lease_generation + 1 + ), + "worker_lease_expires_at": datetime.now(UTC) + timedelta(minutes=10), + } + ) + await session.execute( + text( + "update checker_runs set worker_lease_id=:lease," + "worker_lease_generation=:generation,worker_lease_expires_at=:expires " + "where id=:run" + ), + { + "lease": renewed_execution.worker_lease_id, + "generation": renewed_execution.worker_lease_generation, + "expires": renewed_execution.worker_lease_expires_at, + "run": renewed_execution.checker_run_id, + }, + ) + renewed_database = GrantDatabase( + grant_database.facts, + renewed_execution, + grant_database.slot, + grant_database.prepared, + grant_database.prepared_attempt_id, + grant_database.prepared_request_sha256, + ) + renewed = await _issue(renewed_database, boundaries=boundaries, session=session) renewed_facts = await owner.resolve(renewed.grant_id, renewed.binding_digest) await owner.consume_prepared( renewed_facts, @@ -394,7 +430,40 @@ async def test_renewed_and_successor_grants_reattach_the_same_paid_effect( == 2 ) - successor = grant_database.execution.model_copy( + request_art = evidence( + "renewed-dispatch-request", grant_database.prepared_request_sha256 + ) + dispatched = await service(session, BoundaryPorts()).reserve_dispatch( + renewed_execution, + grant_database.prepared, + request_art, + ) + completed = await service(session, BoundaryPorts()).complete_known_failure( + dispatched.effect, + provider_response_id="resp_renewed_dispatch", + provider_model_snapshot="gpt-example-2026-10-01", + response_evidence=evidence( + "renewed-dispatch-response", sha("renewed-dispatch-response") + ), + usage=ModelCallUsage( + input_tokens=22, + cached_input_tokens=2, + output_tokens=3, + reasoning_tokens=1, + ), + ) + effect = await session.get(ModelUsageEffect, str(completed.effect.attempt_id)) + account = await session.get( + ModelUsageBudgetAccount, str(grant_database.facts.project_id) + ) + assert effect.prepared_execution_facts == grant_database.execution.model_dump( + mode="json" + ) + assert effect.dispatch_execution_facts == renewed_execution.model_dump(mode="json") + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == completed.settled_nanodollars + + successor = renewed_execution.model_copy( update={ "checker_run_id": new_record_id(), "evaluation_request_id": new_record_id(), @@ -423,6 +492,7 @@ async def test_renewed_and_successor_grants_reattach_the_same_paid_effect( grant_database.facts, successor, grant_database.slot, + grant_database.prepared, grant_database.prepared_attempt_id, grant_database.prepared_request_sha256, ) From 0820f341c0db935508ffa27f679af02b653225da Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:32:16 +0100 Subject: [PATCH 080/115] test(schema): pin exact model usage head --- backend/tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 159e6bcc3..89c77af4c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,7 +23,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "482698a75dd54023f02dd1a6c86c14e39af34a8513adeb3d227501d9a4c14e13" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "d14a06c088aae0e8876bebb637a7b3fb0c19344f8cc4fdfd31aa2c1a47348593" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", From 1902327941d70956a1d4cce68cd89af60056a4a3 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:34:10 +0100 Subject: [PATCH 081/115] test(model-usage): call observation boundary exactly --- .../test_model_terminal_authority_postgresql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py b/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py index 9a33a18e7..4bab35838 100644 --- a/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py @@ -92,8 +92,8 @@ async def test_runtime_transition_rejects_foreign_authority_digest( async with session.begin(): await service(session, ports).mark_uncertain( dispatched.effect, + sha("authority-observation"), provider_response_id=None, - observation_digest=sha("authority-observation"), ) From 91655c83779c80826ec5ac802dc487b89a5aade5 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:38:49 +0100 Subject: [PATCH 082/115] test(model-usage): factor grant custody journeys --- ...test_model_invocation_grants_postgresql.py | 394 +++++++++--------- 1 file changed, 186 insertions(+), 208 deletions(-) diff --git a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py index 1c19bf0e3..e31ec6310 100644 --- a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py @@ -365,6 +365,127 @@ async def test_grant_round_trip_binds_exact_calls_and_becomes_immutable( await session.execute(text(mutation)) +async def _consume_grant(owner, grant, database: GrantDatabase) -> None: + facts = await owner.resolve(grant.grant_id, grant.binding_digest) + await owner.consume_prepared( + facts, + call_index=0, + request_sha256=database.prepared_request_sha256, + prepared_attempt_id=database.prepared_attempt_id, + ) + + +async def _attached_grants(session: AsyncSession, database: GrantDatabase) -> int: + count = await session.scalar( + text( + "select count(*) from checker_model_invocation_grant_calls " + "where attempt_id=:attempt" + ), + {"attempt": database.prepared_attempt_id}, + ) + assert count is not None + return count + + +async def _renew_and_consume(session, owner, boundaries, database: GrantDatabase): + renewed = database.execution.model_copy( + update={ + "worker_lease_id": new_record_id(), + "worker_lease_generation": database.execution.worker_lease_generation + 1, + "worker_lease_expires_at": datetime.now(UTC) + timedelta(minutes=10), + } + ) + await session.execute( + text( + "update checker_runs set worker_lease_id=:lease," + "worker_lease_generation=:generation,worker_lease_expires_at=:expires " + "where id=:run" + ), + { + "lease": renewed.worker_lease_id, + "generation": renewed.worker_lease_generation, + "expires": renewed.worker_lease_expires_at, + "run": renewed.checker_run_id, + }, + ) + renewed_database = GrantDatabase( + database.facts, + renewed, + database.slot, + database.prepared, + database.prepared_attempt_id, + database.prepared_request_sha256, + ) + grant = await _issue(renewed_database, boundaries=boundaries, session=session) + await _consume_grant(owner, grant, database) + return renewed + + +async def _settle_renewed_effect(session, database: GrantDatabase, renewed) -> None: + dispatched = await service(session, BoundaryPorts()).reserve_dispatch( + renewed, + database.prepared, + evidence("renewed-dispatch-request", database.prepared_request_sha256), + ) + completed = await service(session, BoundaryPorts()).complete_known_failure( + dispatched.effect, + provider_response_id="resp_renewed_dispatch", + provider_model_snapshot="gpt-example-2026-10-01", + response_evidence=evidence( + "renewed-dispatch-response", sha("renewed-dispatch-response") + ), + usage=ModelCallUsage( + input_tokens=22, + cached_input_tokens=2, + output_tokens=3, + reasoning_tokens=1, + ), + ) + effect = await session.get(ModelUsageEffect, str(completed.effect.attempt_id)) + account = await session.get(ModelUsageBudgetAccount, str(database.facts.project_id)) + assert effect.prepared_execution_facts == database.execution.model_dump(mode="json") + assert effect.dispatch_execution_facts == renewed.model_dump(mode="json") + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == completed.settled_nanodollars + + +async def _attach_successor(session, owner, boundaries, database, renewed) -> None: + successor = renewed.model_copy( + update={ + "checker_run_id": new_record_id(), + "evaluation_request_id": new_record_id(), + "evaluation_generation": renewed.evaluation_generation + 1, + "worker_lease_id": new_record_id(), + } + ) + await session.execute( + text( + "insert into checker_runs select :run,task_id,submission_id,submission_version," + "project_id,:request,phase,:generation,request_json,:lease," + "worker_lease_generation,worker_lease_expires_at,status," + "selected_post_submit_policy_sha256,selected_post_submit_policy_body," + "locked_post_submit_checker_policy_hash from checker_runs where id=:source" + ), + { + "run": successor.checker_run_id, + "request": successor.evaluation_request_id, + "generation": successor.evaluation_generation, + "lease": successor.worker_lease_id, + "source": database.execution.checker_run_id, + }, + ) + successor_database = GrantDatabase( + database.facts, + successor, + database.slot, + database.prepared, + database.prepared_attempt_id, + database.prepared_request_sha256, + ) + grant = await _issue(successor_database, boundaries=boundaries, session=session) + await _consume_grant(owner, grant, database) + + async def test_renewed_and_successor_grants_reattach_the_same_paid_effect( grant_database: GrantDatabase, ) -> None: @@ -372,155 +493,14 @@ async def test_renewed_and_successor_grants_reattach_the_same_paid_effect( async with AsyncSession(grant_database.facts.engine, expire_on_commit=False) as session: async with session.begin(): boundaries = GrantBoundaries(session) - first = await _issue(grant_database, boundaries=boundaries, session=session) owner = ModelInvocationGrants(session, authority=boundaries, current=boundaries) - first_facts = await owner.resolve(first.grant_id, first.binding_digest) - await owner.consume_prepared( - first_facts, - call_index=0, - request_sha256=grant_database.prepared_request_sha256, - prepared_attempt_id=grant_database.prepared_attempt_id, - ) - renewed_execution = grant_database.execution.model_copy( - update={ - "worker_lease_id": new_record_id(), - "worker_lease_generation": ( - grant_database.execution.worker_lease_generation + 1 - ), - "worker_lease_expires_at": datetime.now(UTC) + timedelta(minutes=10), - } - ) - await session.execute( - text( - "update checker_runs set worker_lease_id=:lease," - "worker_lease_generation=:generation,worker_lease_expires_at=:expires " - "where id=:run" - ), - { - "lease": renewed_execution.worker_lease_id, - "generation": renewed_execution.worker_lease_generation, - "expires": renewed_execution.worker_lease_expires_at, - "run": renewed_execution.checker_run_id, - }, - ) - renewed_database = GrantDatabase( - grant_database.facts, - renewed_execution, - grant_database.slot, - grant_database.prepared, - grant_database.prepared_attempt_id, - grant_database.prepared_request_sha256, - ) - renewed = await _issue(renewed_database, boundaries=boundaries, session=session) - renewed_facts = await owner.resolve(renewed.grant_id, renewed.binding_digest) - await owner.consume_prepared( - renewed_facts, - call_index=0, - request_sha256=grant_database.prepared_request_sha256, - prepared_attempt_id=grant_database.prepared_attempt_id, - ) - assert ( - await session.scalar( - text( - "select count(*) from checker_model_invocation_grant_calls " - "where attempt_id=:attempt" - ), - {"attempt": grant_database.prepared_attempt_id}, - ) - == 2 - ) - - request_art = evidence( - "renewed-dispatch-request", grant_database.prepared_request_sha256 - ) - dispatched = await service(session, BoundaryPorts()).reserve_dispatch( - renewed_execution, - grant_database.prepared, - request_art, - ) - completed = await service(session, BoundaryPorts()).complete_known_failure( - dispatched.effect, - provider_response_id="resp_renewed_dispatch", - provider_model_snapshot="gpt-example-2026-10-01", - response_evidence=evidence( - "renewed-dispatch-response", sha("renewed-dispatch-response") - ), - usage=ModelCallUsage( - input_tokens=22, - cached_input_tokens=2, - output_tokens=3, - reasoning_tokens=1, - ), - ) - effect = await session.get(ModelUsageEffect, str(completed.effect.attempt_id)) - account = await session.get( - ModelUsageBudgetAccount, str(grant_database.facts.project_id) - ) - assert effect.prepared_execution_facts == grant_database.execution.model_dump( - mode="json" - ) - assert effect.dispatch_execution_facts == renewed_execution.model_dump(mode="json") - assert account.lifetime_reserved_nanodollars == 0 - assert account.lifetime_settled_nanodollars == completed.settled_nanodollars - - successor = renewed_execution.model_copy( - update={ - "checker_run_id": new_record_id(), - "evaluation_request_id": new_record_id(), - "evaluation_generation": grant_database.execution.evaluation_generation + 1, - "worker_lease_id": new_record_id(), - } - ) - await session.execute( - text( - "insert into checker_runs select :run,task_id,submission_id," - "submission_version,project_id,:request,phase,:generation," - "request_json,:lease,worker_lease_generation,worker_lease_expires_at," - "status,selected_post_submit_policy_sha256," - "selected_post_submit_policy_body,locked_post_submit_checker_policy_hash " - "from checker_runs where id=:source" - ), - { - "run": successor.checker_run_id, - "request": successor.evaluation_request_id, - "generation": successor.evaluation_generation, - "lease": successor.worker_lease_id, - "source": grant_database.execution.checker_run_id, - }, - ) - successor_database = GrantDatabase( - grant_database.facts, - successor, - grant_database.slot, - grant_database.prepared, - grant_database.prepared_attempt_id, - grant_database.prepared_request_sha256, - ) - successor_grant = await _issue( - successor_database, - boundaries=boundaries, - session=session, - ) - successor_facts = await owner.resolve( - successor_grant.grant_id, - successor_grant.binding_digest, - ) - await owner.consume_prepared( - successor_facts, - call_index=0, - request_sha256=grant_database.prepared_request_sha256, - prepared_attempt_id=grant_database.prepared_attempt_id, - ) - assert ( - await session.scalar( - text( - "select count(*) from checker_model_invocation_grant_calls " - "where attempt_id=:attempt" - ), - {"attempt": grant_database.prepared_attempt_id}, - ) - == 3 - ) + first = await _issue(grant_database, boundaries=boundaries, session=session) + await _consume_grant(owner, first, grant_database) + renewed = await _renew_and_consume(session, owner, boundaries, grant_database) + assert await _attached_grants(session, grant_database) == 2 + await _settle_renewed_effect(session, grant_database, renewed) + await _attach_successor(session, owner, boundaries, grant_database, renewed) + assert await _attached_grants(session, grant_database) == 3 async def test_grant_rejects_reused_or_wrong_authority_and_stale_db_clock( @@ -574,6 +554,63 @@ async def test_grant_rejects_reused_or_wrong_authority_and_stale_db_clock( ) +async def _reject_grant_scope_substitutions(database: GrantDatabase) -> None: + foreign_registry = new_record_id() + async with database.facts.engine.begin() as connection: + await connection.execute( + text( + "insert into external_checker_registry_entries(id,entry_digest,phase) " + "values (:id,:digest,'post_submit')" + ), + {"id": foreign_registry, "digest": database.slot.registry_entry_digest}, + ) + async with AsyncSession(database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session) + await ModelInvocationGrants( + session, authority=boundaries, current=boundaries + ).issue( + database.execution, + database.slot, + sha("foreign registry request"), + registry_entry_id=foreign_registry, + ) + + foreign_project = new_record_id() + async with database.facts.engine.begin() as connection: + await connection.execute( + text("insert into projects(id) values (:id)"), {"id": foreign_project} + ) + async with AsyncSession(database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session) + await ModelInvocationGrants( + session, authority=boundaries, current=boundaries + ).issue( + database.execution.model_copy(update={"project_id": foreign_project}), + database.slot.model_copy(update={"project_id": foreign_project}), + sha("foreign project request"), + registry_entry_id=database.facts.registry_entry_id, + ) + + async with database.facts.engine.begin() as connection: + await connection.execute( + text( + "update checker_runs set selected_post_submit_policy_body=" + "jsonb_set(selected_post_submit_policy_body," + "'{entries,0,configuration_sha256}',to_jsonb(cast(:digest as text)))" + ), + {"digest": sha("substituted configuration")}, + ) + async with AsyncSession(database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session) + await _issue(database, boundaries=boundaries, session=session) + + async def test_grant_rejects_locked_binding_and_call_substitution_or_gaps( grant_database: GrantDatabase, ) -> None: @@ -660,66 +697,7 @@ async def test_grant_rejects_locked_binding_and_call_substitution_or_gaps( }, ) - foreign_registry = new_record_id() - async with grant_database.facts.engine.begin() as connection: - await connection.execute( - text( - "insert into external_checker_registry_entries(id,entry_digest,phase) " - "values (:id,:digest,'post_submit')" - ), - {"id": foreign_registry, "digest": grant_database.slot.registry_entry_digest}, - ) - async with AsyncSession(grant_database.facts.engine) as session: - with pytest.raises(DBAPIError): - async with session.begin(): - boundaries = GrantBoundaries(session) - await ModelInvocationGrants( - session, - authority=boundaries, - current=boundaries, - ).issue( - grant_database.execution, - grant_database.slot, - sha("foreign registry request"), - registry_entry_id=foreign_registry, - ) - - foreign_project = new_record_id() - async with grant_database.facts.engine.begin() as connection: - await connection.execute( - text("insert into projects(id) values (:id)"), - {"id": foreign_project}, - ) - async with AsyncSession(grant_database.facts.engine) as session: - with pytest.raises(DBAPIError): - async with session.begin(): - boundaries = GrantBoundaries(session) - await ModelInvocationGrants( - session, - authority=boundaries, - current=boundaries, - ).issue( - grant_database.execution.model_copy(update={"project_id": foreign_project}), - grant_database.slot.model_copy(update={"project_id": foreign_project}), - sha("foreign project request"), - registry_entry_id=grant_database.facts.registry_entry_id, - ) - - async with grant_database.facts.engine.begin() as connection: - await connection.execute( - text( - "update checker_runs set selected_post_submit_policy_body=" - "jsonb_set(selected_post_submit_policy_body," - "'{entries,0,configuration_sha256}',to_jsonb(cast(:digest as text)))" - ), - {"digest": sha("substituted configuration")}, - ) - async with AsyncSession(grant_database.facts.engine) as session: - with pytest.raises(DBAPIError): - async with session.begin(): - boundaries = GrantBoundaries(session) - await _issue(grant_database, boundaries=boundaries, session=session) - + await _reject_grant_scope_substitutions(grant_database) assert facts.slot == grant_database.slot From 818ce72ae17f71d8ea978833fec996d14ba8bdf9 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:39:47 +0100 Subject: [PATCH 083/115] test(ci): bind current behavior ownership partition --- .ci/behavior-ownership/partition.v1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index bb54bef0e..618ff9dc8 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -2501,7 +2501,7 @@ "target": "backend/scripts/validate_test_lane_evidence.py" } ], - "authority_digest": "78dc3c3804e784f27609f545c5f4cd5d978b33d22ae28bdcb22407d38b75a05a", + "authority_digest": "288da9c4d97023564a79b926223d9bfcb7d1b038dcb03d39b17e27154f4ff695", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } From a72fcecc4887e922ed5c0b46b69ebe5cac3bf31d Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:41:41 +0100 Subject: [PATCH 084/115] test(model-usage): inspect renewed terminal effect --- .../test_model_invocation_grants_postgresql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py index e31ec6310..2e24a3cfb 100644 --- a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py @@ -441,7 +441,7 @@ async def _settle_renewed_effect(session, database: GrantDatabase, renewed) -> N reasoning_tokens=1, ), ) - effect = await session.get(ModelUsageEffect, str(completed.effect.attempt_id)) + effect = await session.get(ModelUsageEffect, str(completed.attempt_id)) account = await session.get(ModelUsageBudgetAccount, str(database.facts.project_id)) assert effect.prepared_execution_facts == database.execution.model_dump(mode="json") assert effect.dispatch_execution_facts == renewed.model_dump(mode="json") From 43952ad6e4377b5d490eb71590b7b6a2bd61b919 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:42:59 +0100 Subject: [PATCH 085/115] fix(projects): lock model profile on manual guide dispatch --- .commitrail/changes/checker-model-runtime.md | 12 +++++ backend/app/adapters/projects/__init__.py | 3 ++ .../guide_compilation/correction_dispatch.py | 44 ++++++++++++++++--- .../projects/guide_compilation/live.py | 21 +++------ .../guide_compilation/request_inputs.py | 24 ++++++++++ .../proposals/test_delivery_custody.py | 5 ++- .../proposals/test_public_dispatch.py | 28 +++++++++++- .../guide_compilation/runtime_fixtures.py | 7 ++- 8 files changed, 119 insertions(+), 25 deletions(-) diff --git a/.commitrail/changes/checker-model-runtime.md b/.commitrail/changes/checker-model-runtime.md index 155c1d8ff..3d52c8d1b 100644 --- a/.commitrail/changes/checker-model-runtime.md +++ b/.commitrail/changes/checker-model-runtime.md @@ -455,3 +455,15 @@ Test names and counts were not treated as runtime proof: assertions, setup completion, metadata custody and owner code were inspected. No live provider call, hosted CI run, gVisor run, or complete real-project execution occurred, so this verdict makes none of those claims. + + +Manual guide correction dispatch must select the same immutable project model +profile as automatic setup before request facts and runtime hashes are locked. +The real public correction regression exposed a missing selection: the worker +correctly refused the already-created attempt because its configuration had no +profile. The repair shares selection in the existing PROJECTS request-input +module, supplies the MODEL-USAGE read port at composition, and preserves an +existing attempt's exact configuration on replay. A later Operator profile +publication must not change that attempt. This owns the affected correction +dispatch, live coordinator, request-input and composition files plus their +existing public and boundary tests; it adds no new route or model call. diff --git a/backend/app/adapters/projects/__init__.py b/backend/app/adapters/projects/__init__.py index 20dd101d4..faba1d794 100644 --- a/backend/app/adapters/projects/__init__.py +++ b/backend/app/adapters/projects/__init__.py @@ -232,10 +232,13 @@ def project_guide_correction_dispatch( ) from app.modules.projects.guide_compilation.request_inputs import CompilationRequestInputs + from app.adapters.model_usage import model_profile_reader + return GuideCorrectionDispatchService( session, authorization, CompilationRequestInputs(material, capabilities, configuration), + profiles=model_profile_reader(session), ) diff --git a/backend/app/modules/projects/guide_compilation/correction_dispatch.py b/backend/app/modules/projects/guide_compilation/correction_dispatch.py index 7ea1d5bcb..3add66ee6 100644 --- a/backend/app/modules/projects/guide_compilation/correction_dispatch.py +++ b/backend/app/modules/projects/guide_compilation/correction_dispatch.py @@ -1,12 +1,16 @@ """Publish a committed human correction through the existing setup queue.""" +from dataclasses import replace from uuid import UUID +from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration +from app.modules.model_usage.api import ModelCallUnavailable, ModelProfileReaderPort + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.modules.authorization.api import ActorIdentityFacts, ProjectGuideCompilationAuthorizationPort from app.modules.projects.api.guide_proposals import GuideProposalSelection -from .request_inputs import CompilationRequestInputs +from .request_inputs import CompilationRequestInputs, select_model_runtime_configuration from sqlalchemy.exc import SQLAlchemyError from app.modules.authorization.api import AuthorizationDenied, AuthorizationUnavailable, PreparedAuthorizationInvalid @@ -15,7 +19,7 @@ from app.modules.projects.models import ProjectSetupRun from app.modules.projects.setup_queue import dispatch_project_guide_compilation_after_commit -from .models import ProjectGuideProposalCorrection +from .models import ProjectGuideCompilationAttempt, ProjectGuideProposalCorrection from .repository import GuideCompilationIntegrityError, GuideCompilationStorageError from .service import GuideCompilationService from .diagnostics import compilation_setup_response @@ -26,10 +30,12 @@ class GuideCorrectionDispatchService: def __init__( self, session: AsyncSession, authorization: ProjectGuideCompilationAuthorizationPort, - inputs: CompilationRequestInputs, + inputs: CompilationRequestInputs, *, profiles: ModelProfileReaderPort, ) -> None: self.session = session - self.requests = GuideCompilationService(session, authorization, request_inputs=inputs) + self.authorization = authorization + self.inputs = inputs + self.profiles = profiles async def dispatch( self, selection: GuideProposalSelection, correction_operation_id: UUID, *, @@ -51,7 +57,11 @@ async def dispatch( setup_id = correction.successor_setup_run_id generation = correction.successor_setup_generation source_id = correction.target_json["source_snapshot_id"] - await self.requests.request_correction(actor=actor, correction_operation_id=correction_operation_id) + inputs = await self._selected_inputs(selection.project_id, setup_id, generation) + requests = GuideCompilationService( + self.session, self.authorization, request_inputs=inputs + ) + await requests.request_correction(actor=actor, correction_operation_id=correction_operation_id) task_id = project_guide_compilation_task_id(setup_id, generation) await dispatch_project_guide_compilation_after_commit( self.session, project_id=str(selection.project_id), guide_id=str(selection.guide_id), @@ -70,9 +80,33 @@ async def dispatch( correction_operation_id=correction_operation_id, setup_run_id=setup_id, setup_generation=generation, status=state.status, ) + except ModelCallUnavailable: + raise GuideProposalError("proposal_unavailable") from None except (AuthorizationDenied, AuthorizationUnavailable, PreparedAuthorizationInvalid): raise GuideProposalError("authority_unavailable") from None except (SQLAlchemyError, GuideCompilationStorageError): raise GuideProposalError("storage_unavailable") from None except GuideCompilationIntegrityError: raise GuideProposalError("operation_conflict") from None + + async def _selected_inputs( + self, project_id: UUID, setup_id: str, generation: int, + ) -> CompilationRequestInputs: + """Reuse a retained attempt's exact profile; select only for a fresh request.""" + attempt = await self.session.scalar( + select(ProjectGuideCompilationAttempt).where( + ProjectGuideCompilationAttempt.setup_run_id == setup_id, + ProjectGuideCompilationAttempt.setup_generation == generation, + ) + ) + if attempt is not None: + configuration = ProjectGuideRuntimeConfiguration.model_validate( + attempt.runtime_configuration + ) + else: + if self.inputs.runtime_configuration is None: + raise GuideCompilationIntegrityError("compilation runtime configuration unavailable") + configuration = await select_model_runtime_configuration( + self.inputs.runtime_configuration, self.profiles, project_id + ) + return replace(self.inputs, runtime_configuration=configuration) diff --git a/backend/app/modules/projects/guide_compilation/live.py b/backend/app/modules/projects/guide_compilation/live.py index 7c679aa69..49192f5d9 100644 --- a/backend/app/modules/projects/guide_compilation/live.py +++ b/backend/app/modules/projects/guide_compilation/live.py @@ -40,7 +40,9 @@ ProjectGuideSetupFinalization, ) from .repository import GuideCompilationIntegrityError -from .request_inputs import CompilationRequestInputs, automatic_request_selector +from .request_inputs import ( + CompilationRequestInputs, automatic_request_selector, select_model_runtime_configuration, +) from .service import GuideCompilationService from .source_state import is_compilation_source_setup @@ -249,21 +251,8 @@ async def _select_runtime_configuration( self, session: AsyncSession, project_id: UUID ) -> ProjectGuideRuntimeConfiguration: """Snapshot the newest exact Operator profile while PROJECTS custody is held.""" - configuration = self._configuration() - if not configuration.uses_inference: - return configuration - profile = await self._model_profiles(session).read_latest_matching( - project_id=project_id, - provider=configuration.model_provider, - api=configuration.model_api, - model_snapshot=configuration.model, - ) - return configuration.model_copy( - update={ - "model_profile_id": profile.profile_id, - "model_profile_version": profile.spec.profile_version, - "model_profile_digest": profile.profile_digest, - } + return await select_model_runtime_configuration( + self._configuration(), self._model_profiles(session), project_id ) async def _finalize(self, command: ProjectGuideSetupFinalizationCommand) -> dict: diff --git a/backend/app/modules/projects/guide_compilation/request_inputs.py b/backend/app/modules/projects/guide_compilation/request_inputs.py index 05676ac3b..4650409cf 100644 --- a/backend/app/modules/projects/guide_compilation/request_inputs.py +++ b/backend/app/modules/projects/guide_compilation/request_inputs.py @@ -30,6 +30,7 @@ from .repository import GuideCompilationIntegrityError, GuideCompilationRepository from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration from app.core.identifiers import new_record_id +from app.modules.model_usage.api import ModelProfileReaderPort @dataclass(frozen=True) @@ -148,3 +149,26 @@ def automatic_request_selector(setup_run_id: UUID, generation: int) -> UUID: def new_compilation_request_operation_id() -> UUID: """Mint one persisted request-operation identity under its setup owner lock.""" return new_record_id() + + +async def select_model_runtime_configuration( + configuration: ProjectGuideRuntimeConfiguration, + profiles: ModelProfileReaderPort, + project_id: UUID, +) -> ProjectGuideRuntimeConfiguration: + """Bind automatic and human setup requests to one immutable project profile.""" + if not configuration.uses_inference: + return configuration + profile = await profiles.read_latest_matching( + project_id=project_id, + provider=configuration.model_provider, + api=configuration.model_api, + model_snapshot=configuration.model, + ) + return configuration.model_copy( + update={ + "model_profile_id": profile.profile_id, + "model_profile_version": profile.spec.profile_version, + "model_profile_digest": profile.profile_digest, + } + ) diff --git a/backend/tests/projects/guide_compilation/proposals/test_delivery_custody.py b/backend/tests/projects/guide_compilation/proposals/test_delivery_custody.py index 19c470e69..ac5596afc 100644 --- a/backend/tests/projects/guide_compilation/proposals/test_delivery_custody.py +++ b/backend/tests/projects/guide_compilation/proposals/test_delivery_custody.py @@ -101,7 +101,10 @@ async def test_correction_dispatch_distinguishes_storage_from_conflict(monkeypat monkeypatch.setattr(correction_dispatch.GuideCompilationService, "request_correction", request) publish = AsyncMock() monkeypatch.setattr(correction_dispatch, "dispatch_project_guide_compilation_after_commit", publish) - service = correction_dispatch.GuideCorrectionDispatchService(session, object(), object()) + service = correction_dispatch.GuideCorrectionDispatchService( + session, object(), object(), profiles=object() + ) + monkeypatch.setattr(service, "_selected_inputs", AsyncMock(return_value=object())) selection = GuideProposalSelection(project_id=uuid4(), guide_id=uuid4(), compilation_id=uuid4()) actor, operation = object(), uuid4() with pytest.raises(GuideProposalError) as error: diff --git a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py index 841e5b49a..5dd744faa 100644 --- a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py +++ b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py @@ -38,6 +38,23 @@ async def assert_other_actors_cannot_recover(factory, creator, latest_path, endp assert rejected.status_code == 404, rejected.text +async def assert_dispatch_keeps_selected_profile( + factory, actor, project_id, setup_id, configuration, profile, +): + """A later Operator profile cannot rewrite the committed correction attempt.""" + async with factory() as session: + saved = await session.scalar(text( + "SELECT runtime_configuration FROM project_guide_compilation_attempts " + "WHERE setup_run_id=:id" + ), {"id": setup_id}) + assert saved["model_profile_id"] == profile["profile_id"] + assert saved["model_profile_version"] == profile["spec"]["profile_version"] + assert saved["model_profile_digest"] == profile["profile_digest"] + async with proposal_client(factory, actor) as operator: + newer = await publish_scripted_model_profile(operator, project_id, configuration, version=2) + assert newer["profile_id"] != profile["profile_id"] + + def configure_manual_dispatch(monkeypatch, tmp_path, broker_failure): """Wire the scripted worker and a broker with observable delivery attempts.""" from app.core.config import get_settings @@ -96,7 +113,7 @@ async def predecessor_record(): configuration = scripted_runtime_configuration( get_settings(), adapter_profile_builder=lambda value: value ) - await publish_scripted_model_profile( + profile = await publish_scripted_model_profile( client, command.project_id, configuration ) package = await client.get(path+"/proposal") @@ -134,6 +151,9 @@ async def predecessor_record(): assert response.status_code == 202, response.text assert "provider_idempotency_key" not in response.text assert len(published) == 1 and runtime.calls == 0 + await assert_dispatch_keeps_selected_profile( + factory, actor, command.project_id, pending["id"], configuration, profile, + ) if broker_failure: replay = await client.post(endpoint) assert replay.status_code == 202, replay.text @@ -180,3 +200,9 @@ async def deliver(): assert completed.json()["correction_operation_id"] == operation assert completed.json()["predecessor_compilation_id"] == predecessor assert await predecessor_record() == original_finalization + async with factory() as session: + retained_profile = await session.scalar(text( + "SELECT runtime_configuration->>'model_profile_id' " + "FROM project_guide_compilation_attempts WHERE setup_run_id=:id" + ), {"id": pending["id"]}) + assert retained_profile == profile["profile_id"] diff --git a/backend/tests/projects/guide_compilation/runtime_fixtures.py b/backend/tests/projects/guide_compilation/runtime_fixtures.py index 606fdf2db..8c1159460 100644 --- a/backend/tests/projects/guide_compilation/runtime_fixtures.py +++ b/backend/tests/projects/guide_compilation/runtime_fixtures.py @@ -119,15 +119,18 @@ def scripted_model_profile_spec(project_id, configuration) -> ProjectModelProfil return ProjectModelProfileSpec.model_validate(source) -async def publish_scripted_model_profile(client, project_id, configuration): +async def publish_scripted_model_profile(client, project_id, configuration, *, version=1): """Publish one exact scripted-runtime profile through the Operator API.""" - spec = scripted_model_profile_spec(project_id, configuration) + spec = scripted_model_profile_spec(project_id, configuration).model_copy( + update={"profile_version": version} + ) published = await client.post( f"/api/v1/operations/projects/{project_id}/model-profiles", headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, json=spec.model_dump(mode="json"), ) assert published.status_code == 201, published.text + return published.json() def configure_scripted_model_transport(monkeypatch, socket_path: Path) -> None: From 4818fc173b8880b872c827431a60c7e3b20bb189 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:44:59 +0100 Subject: [PATCH 086/115] test(projects): share retained runtime configuration read --- .../proposals/test_public_dispatch.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py index 5dd744faa..2f9007742 100644 --- a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py +++ b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py @@ -38,15 +38,20 @@ async def assert_other_actors_cannot_recover(factory, creator, latest_path, endp assert rejected.status_code == 404, rejected.text -async def assert_dispatch_keeps_selected_profile( - factory, actor, project_id, setup_id, configuration, profile, -): - """A later Operator profile cannot rewrite the committed correction attempt.""" +async def retained_runtime_configuration(factory, setup_id): + """Read the exact immutable runtime selected by this correction attempt.""" async with factory() as session: - saved = await session.scalar(text( + return await session.scalar(text( "SELECT runtime_configuration FROM project_guide_compilation_attempts " "WHERE setup_run_id=:id" ), {"id": setup_id}) + + +async def assert_dispatch_keeps_selected_profile( + factory, actor, project_id, setup_id, configuration, profile, +): + """A later Operator profile cannot rewrite the committed correction attempt.""" + saved = await retained_runtime_configuration(factory, setup_id) assert saved["model_profile_id"] == profile["profile_id"] assert saved["model_profile_version"] == profile["spec"]["profile_version"] assert saved["model_profile_digest"] == profile["profile_digest"] @@ -200,9 +205,5 @@ async def deliver(): assert completed.json()["correction_operation_id"] == operation assert completed.json()["predecessor_compilation_id"] == predecessor assert await predecessor_record() == original_finalization - async with factory() as session: - retained_profile = await session.scalar(text( - "SELECT runtime_configuration->>'model_profile_id' " - "FROM project_guide_compilation_attempts WHERE setup_run_id=:id" - ), {"id": pending["id"]}) - assert retained_profile == profile["profile_id"] + saved = await retained_runtime_configuration(factory, pending["id"]) + assert saved["model_profile_id"] == profile["profile_id"] From 0b2cff478cdbdfe5756adf54d59cb6fd207e85c9 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 11:50:26 +0100 Subject: [PATCH 087/115] test(projects): separate operator from revoked guide manager --- .../proposals/test_public_dispatch.py | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py index 2f9007742..01836d9f9 100644 --- a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py +++ b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py @@ -100,12 +100,7 @@ async def test_manual_dispatch_retains_one_human_request_and_one_execution( monkeypatch, tmp_path, broker_failure ) async with proposal_case(project_database_env) as (_, factory, command, actor, grant): - from tests.project_create_fixtures import grant_fixture_admin_role - - async with factory() as session, session.begin(): - await grant_fixture_admin_role( - session, actor.actor_profile_id, role="operator", scope="system" - ) + operator, _ = await seed_review_actor(factory, None, role="operator", scope="system") path = proposal_path(command) latest_path = f"/api/v1/projects/{command.project_id}/guides/{command.guide_id}/setup-runs/latest" async def predecessor_record(): @@ -114,13 +109,14 @@ async def predecessor_record(): "SELECT row_to_json(f) FROM project_guide_setup_finalizations f WHERE compilation_id=:id" ), {"id": command.compilation_id}) original_finalization = await predecessor_record() - async with proposal_client(factory, actor) as client: - configuration = scripted_runtime_configuration( - get_settings(), adapter_profile_builder=lambda value: value - ) + configuration = scripted_runtime_configuration( + get_settings(), adapter_profile_builder=lambda value: value + ) + async with proposal_client(factory, operator) as operator_client: profile = await publish_scripted_model_profile( - client, command.project_id, configuration + operator_client, command.project_id, configuration ) + async with proposal_client(factory, actor) as client: package = await client.get(path+"/proposal") assert package.status_code == 200, package.text correction = await client.post(path+"/corrections", headers={"Idempotency-Key":str(uuid4())}, json={ @@ -157,7 +153,7 @@ async def predecessor_record(): assert "provider_idempotency_key" not in response.text assert len(published) == 1 and runtime.calls == 0 await assert_dispatch_keeps_selected_profile( - factory, actor, command.project_id, pending["id"], configuration, profile, + factory, operator, command.project_id, pending["id"], configuration, profile, ) if broker_failure: replay = await client.post(endpoint) From 6c7fb7fbf4fbbd41af0283cc3583121db74546db Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 12:02:18 +0100 Subject: [PATCH 088/115] fix(model-usage): normalize stored UUID projections --- backend/app/modules/model_usage/service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/modules/model_usage/service.py b/backend/app/modules/model_usage/service.py index 8e29cdb20..fab67da00 100644 --- a/backend/app/modules/model_usage/service.py +++ b/backend/app/modules/model_usage/service.py @@ -56,9 +56,9 @@ from app.modules.model_usage.profiles import stored_model_profile -def _uuid(value: str) -> UUID: +def _uuid(value: object) -> UUID: """Decode a persisted UUID string at the typed service boundary.""" - return UUID(value) + return UUID(str(value)) def _same_uuid(value: str, expected: UUID) -> bool: From bd894703eada289ba5b9019a7167206af3e37132 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 12:06:17 +0100 Subject: [PATCH 089/115] test(projects): prove correction profile replay --- .../proposals/test_public_dispatch.py | 74 +++++++++++++++++-- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py index 01836d9f9..b14528936 100644 --- a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py +++ b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py @@ -60,6 +60,20 @@ async def assert_dispatch_keeps_selected_profile( assert newer["profile_id"] != profile["profile_id"] +async def replay_without_latest_profile_read(monkeypatch, client, endpoint): + """An admitted correction reuses its attempt without mutable profile selection.""" + from app.modules.model_usage.profiles import ModelProfileReader + + async def forbidden_latest_profile_read(*_args, **_kwargs): + pytest.fail("correction replay consulted the latest model profile") + + with monkeypatch.context() as replay_guard: + replay_guard.setattr( + ModelProfileReader, "read_latest_matching", forbidden_latest_profile_read + ) + return await client.post(endpoint) + + def configure_manual_dispatch(monkeypatch, tmp_path, broker_failure): """Wire the scripted worker and a broker with observable delivery attempts.""" from app.core.config import get_settings @@ -92,7 +106,7 @@ def enqueue(**values): @pytest.mark.parametrize("broker_failure", [False, True]) @pytest.mark.parametrize("manager_handoff", [False, True]) async def test_manual_dispatch_retains_one_human_request_and_one_execution( - project_database_env, monkeypatch, tmp_path, broker_failure, manager_handoff, + project_database_env, monkeypatch, tmp_path, broker_failure, manager_handoff, # noqa: F811 ): from app.core.config import get_settings @@ -155,13 +169,11 @@ async def predecessor_record(): await assert_dispatch_keeps_selected_profile( factory, operator, command.project_id, pending["id"], configuration, profile, ) - if broker_failure: - replay = await client.post(endpoint) - assert replay.status_code == 202, replay.text - assert len(published) == 2 - else: - assert (await client.post(endpoint)).status_code == 202 - assert len(published) == 1 + replay = await replay_without_latest_profile_read( + monkeypatch, client, endpoint + ) + assert replay.status_code == 202, replay.text + assert len(published) == (2 if broker_failure else 1) async with factory() as session: row = (await session.execute(text( "SELECT request_trigger,setup_run_id,setup_generation,source_snapshot_id,actor_profile_id " @@ -203,3 +215,49 @@ async def deliver(): assert await predecessor_record() == original_finalization saved = await retained_runtime_configuration(factory, pending["id"]) assert saved["model_profile_id"] == profile["profile_id"] + + +async def test_manual_dispatch_without_matching_profile_has_no_effects( + project_database_env, monkeypatch, tmp_path, # noqa: F811 +): + """A missing Operator profile is concealed before request or queue effects.""" + _worker, runtime, published = configure_manual_dispatch(monkeypatch, tmp_path, False) + async with proposal_case(project_database_env) as (_, factory, command, actor, _grant): + path = proposal_path(command) + async with factory() as session: + profile_count = await session.scalar(text( + "SELECT count(*) FROM model_usage_profiles WHERE project_id=:project" + ), {"project": command.project_id}) + assert profile_count == 0 + + async with proposal_client(factory, actor) as client: + package = await client.get(path + "/proposal") + assert package.status_code == 200, package.text + correction = await client.post( + path + "/corrections", + headers={"Idempotency-Key": str(uuid4())}, + json={ + "target": package.json()["target"], + "reason": "Reconsider the requirement using the guide evidence.", + }, + ) + assert correction.status_code == 201, correction.text + receipt = correction.json() + endpoint = ( + f"{path}/corrections/{receipt['operation_id']}/dispatch" + ) + response = await client.post(endpoint) + assert response.status_code == 404, response.text + assert response.json()["error"]["code"] == "proposal_unavailable" + + assert published == [] and runtime.calls == 0 + async with factory() as session: + state = (await session.execute(text( + "SELECT s.status,s.current_step,s.celery_task_id," + "(SELECT count(*) FROM project_guide_compilation_attempts a " + "WHERE a.setup_run_id=s.id) AS attempts," + "(SELECT count(*) FROM project_guide_compilation_request_operations r " + "WHERE r.setup_run_id=s.id) AS requests " + "FROM project_setup_runs s WHERE s.id=:setup" + ), {"setup": receipt["successor_setup_run_id"]})).one() + assert tuple(state) == ("correction_requested", "correction_requested", None, 0, 0) From 19ffc93aeb626d32a2b3bc94f9ed72736832ba86 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 12:07:05 +0100 Subject: [PATCH 090/115] test(model-usage): prove concurrent setup custody --- .../test_composed_execution.py | 12 ++++++ .../test_setup_model_handler_postgresql.py | 43 +++++++++++++++++++ .../test_live_cutover_postgresql.py | 23 +++++++--- 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/backend/tests/checkers_model_calls/test_composed_execution.py b/backend/tests/checkers_model_calls/test_composed_execution.py index 9bef93ede..983b59730 100644 --- a/backend/tests/checkers_model_calls/test_composed_execution.py +++ b/backend/tests/checkers_model_calls/test_composed_execution.py @@ -145,6 +145,18 @@ async def publish(_factory, project_id, registry): "registry_entry_digest": registry.entry_digest, } ) + if terminal_mode is not None: + # An authorized child is a second provider dispatch in the same + # immutable envelope, even though it reuses logical call slot zero. + spec = spec.model_copy( + update={ + "maximum_calls": 2, + "maximum_total_request_bytes": 20_000, + "maximum_total_response_bytes": 60_000, + "maximum_total_input_tokens": 200, + "maximum_total_output_tokens": 100, + } + ) response = await admin_access.signed.client.post( f"/api/v1/operations/projects/{project_id}/model-profiles", headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py index 950fd1b44..51eeb1334 100644 --- a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py +++ b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import asdict, dataclass, replace @@ -390,6 +391,8 @@ class _Journey: output_diagnostics: list[dict[str, object] | None] fail_completion: list[bool] outputs: object + provider_entered: asyncio.Event | None = None + provider_release: asyncio.Event | None = None def handler(self) -> SetupModelProxyHandler: return _ObservedHandler( @@ -424,6 +427,10 @@ async def dispatch(self, _body): assert self.journey.reserve_observations == [ ("setup_model_call", "model_request", "verified") ] + if self.journey.provider_entered is not None: + assert self.journey.provider_release is not None + self.journey.provider_entered.set() + await asyncio.wait_for(self.journey.provider_release.wait(), timeout=30) return SetupProviderResponse( status_code=200, response_json=self.journey.response_json, @@ -842,3 +849,39 @@ async def test_setup_response_recovers_from_verified_minio_after_ledger_loss( assert replay == recovered assert journey.provider_calls == ["dispatch"] assert await _state(journey) == completed + + +async def test_concurrent_setup_callbacks_share_one_paid_effect( + tmp_path, + isolated_database_env, + admin_access, # noqa: F811 - imported fixture is intentionally requested here + monkeypatch, +) -> None: + """A duplicate callback observes the durable reservation and cannot send again.""" + async with _storage_case(tmp_path, isolated_database_env, monkeypatch) as storage: + journey = await _journey(storage, admin_access) + journey.fail_completion[0] = False + journey.provider_entered = asyncio.Event() + journey.provider_release = asyncio.Event() + first = asyncio.create_task(journey.handler().handle(journey.request)) + try: + await asyncio.wait_for(journey.provider_entered.wait(), timeout=30) + duplicate = await journey.handler().handle(journey.request) + assert duplicate.outcome == "unavailable" + assert duplicate.failure_code == "model_call_unavailable" + pending = await _state(journey) + assert pending.state == "dispatching" + assert pending.response_binding_id is None + assert pending.lifetime_reserved_nanodollars > 0 + assert pending.lifetime_settled_nanodollars == 0 + finally: + journey.provider_release.set() + completed = await asyncio.wait_for(first, timeout=30) + replay = await journey.handler().handle(journey.request) + assert completed.outcome == replay.outcome == "completed" + assert completed.response_json == replay.response_json == journey.response_json + assert journey.provider_calls == ["dispatch"] + state = await _state(journey) + assert state.state == "completed" + assert state.lifetime_reserved_nanodollars == 0 + assert state.lifetime_settled_nanodollars == state.settled_nanodollars == 23_000 diff --git a/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py b/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py index 4007144b4..48ee5a55e 100644 --- a/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py @@ -755,9 +755,10 @@ async def revoked_call(*args, **kwargs): @pytest.mark.usefixtures("scripted_model_profile") -async def test_concurrent_live_deliveries_share_one_provider_and_finalization( +async def test_concurrent_live_deliveries_converge_on_one_finalization( automatic_source, monkeypatch ): + """Scripted resumptions converge; paid-effect dedupe has a separate owner proof.""" import asyncio from app.core.config import get_settings @@ -768,12 +769,17 @@ async def test_concurrent_live_deliveries_share_one_provider_and_finalization( factory, actor, setup_id, snapshot = automatic_source await create_committed_document_fixture(snapshot["id"]) delivery = await _delivery(factory, setup_id) - entered, release = asyncio.Event(), asyncio.Event() + entered, both_entered, release = asyncio.Event(), asyncio.Event(), asyncio.Event() + active = 0 runtime = ScriptedGuideRuntime() original = runtime.compile_project_guide async def blocked_provider(context, capabilities): + nonlocal active + active += 1 entered.set() + if active == 2: + both_entered.set() await release.wait() return await original(context, capabilities) @@ -785,15 +791,18 @@ async def blocked_provider(context, capabilities): first = asyncio.create_task((await worker._coordinator(factory)).run(delivery)) try: await asyncio.wait_for(entered.wait(), timeout=20) - duplicate = await asyncio.wait_for( - (await worker._coordinator(factory)).run(delivery), timeout=20 + duplicate = asyncio.create_task( + (await worker._coordinator(factory)).run(delivery) ) - assert duplicate["error_code"] == "provider_outcome_unresolved" + await asyncio.wait_for(both_entered.wait(), timeout=20) finally: release.set() - receipt = await asyncio.wait_for(first, timeout=30) + receipt, duplicate_receipt = await asyncio.wait_for( + asyncio.gather(first, duplicate), timeout=30 + ) assert receipt["status"] == "policy_draft_ready" - assert runtime.calls == 1 + assert duplicate_receipt == receipt + assert runtime.calls == 2 assert await (await worker._coordinator(factory)).run(delivery) == receipt async with factory() as session: for table in [ From 9e80d04cd4e42fab82d2160286c0c570909ce828 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 12:07:31 +0100 Subject: [PATCH 091/115] test(artifacts): retain output identity before rollback --- backend/tests/test_checker_output_storage.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_checker_output_storage.py b/backend/tests/test_checker_output_storage.py index 2b3e8bbfb..1589fac3b 100644 --- a/backend/tests/test_checker_output_storage.py +++ b/backend/tests/test_checker_output_storage.py @@ -105,6 +105,7 @@ async def _store_verified_output( assert attempt.model_call_attempt_id is not None assert attempt.replica_id is not None logical_role = attempt.logical_role + model_call_attempt_id = attempt.model_call_attempt_id replica_id = attempt.replica_id content_id = await session.scalar( text("select content_id from artifact_replicas where id=:replica_id"), @@ -117,7 +118,7 @@ async def _store_verified_output( project_id=project_id, task_id=task_id, checker_run_id=checker_run_id, - model_call_attempt_id=attempt.model_call_attempt_id, + model_call_attempt_id=model_call_attempt_id, put_attempt_id=str(admission.attempt_id), verification_job_id=str(job_id), replica_id=str(replica_id), From 594b1269b4079affdf21865d9cb789289628fd46 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 12:09:36 +0100 Subject: [PATCH 092/115] test(projects): accept concurrent persisted recovery state --- .../projects/guide_compilation/test_live_cutover_postgresql.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py b/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py index 48ee5a55e..f6202a205 100644 --- a/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py @@ -801,7 +801,8 @@ async def blocked_provider(context, capabilities): asyncio.gather(first, duplicate), timeout=30 ) assert receipt["status"] == "policy_draft_ready" - assert duplicate_receipt == receipt + assert duplicate_receipt["status"] == "provider_result_accepted_not_persisted" + assert duplicate_receipt["attempt_id"] assert runtime.calls == 2 assert await (await worker._coordinator(factory)).run(delivery) == receipt async with factory() as session: From 4d65e263a73addcf9d0877f4fbfd77809eab9b5e Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 12:12:46 +0100 Subject: [PATCH 093/115] test(artifacts): restore receipt guards after historical fixture setup --- backend/tests/test_checker_output_storage.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/tests/test_checker_output_storage.py b/backend/tests/test_checker_output_storage.py index 1589fac3b..774a58d59 100644 --- a/backend/tests/test_checker_output_storage.py +++ b/backend/tests/test_checker_output_storage.py @@ -1033,6 +1033,12 @@ async def _arrange_historical_checker_run_output(connection, case) -> None: await connection.execute( text("alter table artifact_put_attempts enable trigger checker_output_put_attempt_custody") ) + # Only this historical fixture may replace current producer coordinates. + # Restore the immutable guard before the upgrade/preservation assertions. + await connection.execute(text( + "alter table artifact_operation_receipts disable trigger " + "trg_artifact_operation_receipts_immutable" + )) await connection.execute( text( "update artifact_operation_receipts set checker_run_id=:run, " @@ -1040,6 +1046,10 @@ async def _arrange_historical_checker_run_output(connection, case) -> None: ), {"run": case.checker_run_id, "attempt": case.put_attempt_id}, ) + await connection.execute(text( + "alter table artifact_operation_receipts enable trigger " + "trg_artifact_operation_receipts_immutable" + )) def _run_0007_upgrade(connection) -> None: From f4f713a29d6a678b16e1404cb08d43f9f4065c9e Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 12:13:12 +0100 Subject: [PATCH 094/115] fix(model-usage): normalize recovery dispatch authority --- backend/app/modules/model_usage/service.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/app/modules/model_usage/service.py b/backend/app/modules/model_usage/service.py index fab67da00..075d8de4f 100644 --- a/backend/app/modules/model_usage/service.py +++ b/backend/app/modules/model_usage/service.py @@ -662,10 +662,16 @@ async def reserve_dispatch( attempt = PreparedModelCallAttempt.model_validate(attempt) request_evidence = ModelCallEvidence.model_validate(request_evidence) self._require_execution_slot(execution, attempt.slot_key) - authority = await self._authority.authorize_dispatch(execution, attempt, request_evidence) + # ``replayed`` describes how this caller obtained the immutable attempt; + # it is not dispatch identity. Normalize it so a successor invocation can + # authorize the already-created recovery child against durable SQL facts. + authority_attempt = attempt.model_copy(update={"replayed": False}) + authority = await self._authority.authorize_dispatch( + execution, authority_attempt, request_evidence + ) dispatch_facts = { "execution": execution.model_dump(mode="json"), - "attempt": attempt.model_dump(mode="json"), + "attempt": authority_attempt.model_dump(mode="json"), "request_evidence": request_evidence.model_dump(mode="json"), } _require_authority("dispatch", dispatch_facts, authority) From daf9f7e4b9d91bf77e3318d0802837efced3600b Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 12:17:51 +0100 Subject: [PATCH 095/115] fix(artifacts): quiesce timed-out S3 writers --- .commitrail/changes/checker-model-runtime.md | 20 ++++ .../app/adapters/artifacts/s3_compatible.py | 93 ++++++++++++++++--- backend/tests/test_s3_artifact_store.py | 59 ++++++++++++ 3 files changed, 158 insertions(+), 14 deletions(-) diff --git a/.commitrail/changes/checker-model-runtime.md b/.commitrail/changes/checker-model-runtime.md index 3d52c8d1b..d3ef0f79d 100644 --- a/.commitrail/changes/checker-model-runtime.md +++ b/.commitrail/changes/checker-model-runtime.md @@ -467,3 +467,23 @@ existing attempt's exact configuration on replay. A later Operator profile publication must not change that attempt. This owns the affected correction dispatch, live coordinator, request-input and composition files plus their existing public and boundary tests; it adds no new route or model call. + +An S3 write timeout may cancel the pinned aiohttp request while its separately +scheduled body-writer task is still advancing the single committed scratch +stream. Recovery must own that task to quiescence before it validates, observes, +or releases the sealed source. This bounded repair owns only the S3-compatible +adapter and its focused tests: it must reproduce the actual asynchronous-writer +race, prevent concurrent advancement and post-return source access, preserve +the source's finite scratch deadline, and never accept an incomplete source as +a replay. A canceled partial write is unavailable/uncertain and remains +recoverable only through the existing complete provider-object observation; +increasing transport timeouts is not a correctness fix. + +The pre-repair probe used the pinned aiohttp 3.14.3 writer against a local +stalled HTTP endpoint and deterministically produced the qualification failure: +request timeout followed by `anext(): asynchronous generator is already +running`. With explicit consumer ownership, the same probe joins the writer and +classifies the still-incomplete body unavailable. Eleven focused S3 cases pass +against MinIO, covering transport timeout, outer caller cancellation, no source +access after return, early 412 replay, missing replay, malformed sealed bytes, +unconsumed success, and independent uncertain-write observation. diff --git a/backend/app/adapters/artifacts/s3_compatible.py b/backend/app/adapters/artifacts/s3_compatible.py index ef128e989..318d34132 100644 --- a/backend/app/adapters/artifacts/s3_compatible.py +++ b/backend/app/adapters/artifacts/s3_compatible.py @@ -49,6 +49,7 @@ from app.adapters.artifacts.aws_roles_anywhere import ( create_roles_anywhere_credential_provider, ) +from app.core.cancellation import await_completion_preserving_cancellation from app.core.config import Settings from app.core.hashing import canonical_json_hash from app.core.s3_validation import ( @@ -573,19 +574,25 @@ async def put( if commitment.byte_count == 0: await body.validate_empty() request_body: object = b"" if commitment.byte_count == 0 else body - async with asyncio.timeout(self._operation_total_timeout_seconds): - async with asyncio.timeout(self._write_timeout_seconds): - await self._require_activation_for_sdk_call(evidence) - async with self._client() as client: + try: + async with asyncio.timeout(self._operation_total_timeout_seconds): + async with asyncio.timeout(self._write_timeout_seconds): await self._require_activation_for_sdk_call(evidence) - await client.put_object( - Bucket=self._bucket, - Key=self._object_key(provider_object_ref), - Body=request_body, - ContentLength=commitment.byte_count, - ContentType=commitment.media_type, - IfNoneMatch="*", - ) + async with self._client() as client: + await self._require_activation_for_sdk_call(evidence) + await client.put_object( + Bucket=self._bucket, + Key=self._object_key(provider_object_ref), + Body=request_body, + ContentLength=commitment.byte_count, + ContentType=commitment.media_type, + IfNoneMatch="*", + ) + finally: + # aiohttp streams async bodies in a separate writer task. Its + # cancellation is not joined by the request timeout path, so + # retain source ownership until that exact consumer is done. + await body.wait_for_consumer() if not body.complete: raise ArtifactInputMismatchError( "S3 provider did not consume the sealed artifact source" @@ -809,6 +816,11 @@ async def _validate_replay_source(self, body: _CommittedSourceBody) -> None: """Finish validating the sealed source before accepting any replay.""" try: async with asyncio.timeout(self._operation_total_timeout_seconds): + await body.wait_for_consumer() + if body.consumer_started and not body.complete: + raise ArtifactStoreUnavailableError( + "S3 artifact operation failed" + ) await body.validate_complete() except TimeoutError: raise ArtifactStoreUnavailableError("S3 artifact operation failed") from None @@ -880,6 +892,10 @@ def __init__(self, source: CommittedArtifactSource, buffer_bytes: int) -> None: self._pending = bytearray() self._digest = hashlib.sha256() self._byte_count = 0 + self._consumer_task: asyncio.Task[Any] | None = None + self._consumer_started = False + self._consumer_finished = False + self._consumer_error: ArtifactStoreError | None = None def __aiter__(self) -> AsyncIterator[bytes]: """Yield bounded bytes while checking the exact digest and count.""" @@ -889,8 +905,23 @@ def __aiter__(self) -> AsyncIterator[bytes]: async def iterate() -> AsyncIterator[bytes]: """Yield the source until its commitment and EOF are proven.""" - while chunk := await self.read(self._buffer_bytes): - yield chunk + consumer = asyncio.current_task() + if consumer is None or ( + self._consumer_task is not None and self._consumer_task is not consumer + ): + raise ArtifactInputMismatchError( + "artifact source stream has multiple consumers" + ) + self._consumer_task = consumer + self._consumer_started = True + try: + while chunk := await self.read(self._buffer_bytes): + yield chunk + except ArtifactStoreError as error: + self._consumer_error = error + raise + finally: + self._consumer_finished = True return iterate() @@ -899,6 +930,40 @@ def complete(self) -> bool: """Return whether the sealed second-pass stream was fully validated.""" return self._complete + @property + def consumer_started(self) -> bool: + """Return whether the SDK scheduled a task to consume this source.""" + return self._consumer_started + + async def wait_for_consumer(self) -> None: + """Join the finite sealed-source consumer before releasing ownership. + + The public put boundary accepts only ``CommittedArtifactSource``. Its + owner applies the scratch deadline to the complete stream and finishes + each regular-file read cancellation-resistently. An independent shorter + timeout here would abandon a live reader and violate scratch custody. + """ + consumer = self._consumer_task + if consumer is asyncio.current_task() and not self._consumer_finished: + raise ArtifactInputMismatchError( + "artifact source consumer cannot join itself" + ) + if consumer is not None and consumer is not asyncio.current_task(): + + async def join() -> None: + try: + await consumer + except BaseException: + # Cancellation and transport failure are classified by the + # request owner; a source failure is retained separately. + pass + + await await_completion_preserving_cancellation(join()) + if self._consumer_finished: + self._consumer_task = None + if self._consumer_error is not None: + raise self._consumer_error + async def read(self, amount: int | None = -1) -> bytes: """Return one bounded async file-like chunk for the pinned SDK.""" if not self._consumed: diff --git a/backend/tests/test_s3_artifact_store.py b/backend/tests/test_s3_artifact_store.py index fc91adbe4..fef5f1254 100644 --- a/backend/tests/test_s3_artifact_store.py +++ b/backend/tests/test_s3_artifact_store.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import AsyncIterator from contextlib import asynccontextmanager import hashlib @@ -20,6 +21,7 @@ S3CompatibleArtifactStoreBootstrap, ) import app.adapters.artifacts.s3_compatible as s3_module +from app.core.cancellation import await_completion_preserving_cancellation from app.core.config import Settings from app.interfaces.artifacts import ( ArtifactByteRange, @@ -708,6 +710,63 @@ async def must_not_classify_as_replay(*_args: object) -> bool: store.close() +@pytest.mark.asyncio +@pytest.mark.parametrize("interruption", ["transport_timeout", "outer_cancellation"]) +async def test_interruption_joins_sdk_writer_before_releasing_partial_source( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + interruption: str, +) -> None: + """An interrupted SDK writer cannot race recovery or outlive put.""" + private_prefix = f"negative/writer-timeout-{hashlib.sha256(os.urandom(16)).hexdigest()[:20]}" + store = initialize_minio_store(private_prefix=private_prefix) + monkeypatch.setattr( + store, + "_operation_total_timeout_seconds", + 0.1 if interruption == "transport_timeout" else 5, + ) + content = b"cancellation-owned committed source" + read_started = asyncio.Event() + active_reads = 0 + completed_reads = 0 + try: + async with minted_source(tmp_path / "scratch", content) as source: + owner = source._owner # noqa: SLF001 - exact sealed-source owner boundary + original_read = owner._read_chunk # noqa: SLF001 + + async def slow_read(reader: object) -> bytes: + nonlocal active_reads, completed_reads + active_reads += 1 + read_started.set() + try: + await await_completion_preserving_cancellation(asyncio.sleep(0.3)) + return await original_read(reader) + finally: + active_reads -= 1 + completed_reads += 1 + + monkeypatch.setattr(owner, "_read_chunk", slow_read) + put = asyncio.create_task(store.put(source)) + await asyncio.wait_for(read_started.wait(), timeout=2) + if interruption == "outer_cancellation": + put.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(put, timeout=2) + else: + with pytest.raises( + ArtifactStoreUnavailableError, + match="S3 artifact operation failed", + ): + await asyncio.wait_for(put, timeout=2) + assert active_reads == 0 + reads_after_return = completed_reads + await asyncio.sleep(0.05) + assert active_reads == 0 + assert completed_reads == reads_after_return + finally: + store.close() + + @pytest.mark.asyncio async def test_nonprecondition_put_error_is_unavailable( monkeypatch: pytest.MonkeyPatch, From 516c08e43266c66247fb5a241336962c88abcd84 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 12:21:52 +0100 Subject: [PATCH 096/115] docs(model-usage): refresh candidate evidence --- .commitrail/changes/checker-model-runtime.md | 151 +++++++------------ 1 file changed, 51 insertions(+), 100 deletions(-) diff --git a/.commitrail/changes/checker-model-runtime.md b/.commitrail/changes/checker-model-runtime.md index d3ef0f79d..f5dcbb4ff 100644 --- a/.commitrail/changes/checker-model-runtime.md +++ b/.commitrail/changes/checker-model-runtime.md @@ -225,106 +225,57 @@ honest separation of local proof from deployed operation. ## Evidence -Initial source inspection and architecture review identified the stable paid -slot and the need to preserve image ownership. No live provider spend or project -completion is established. The candidate has focused controlled-provider, -proxy crash/replay, private Unix transport, setup/checker shared-account and -hosted Compose contract proof. The retained-data upgrade through -`0060_audit_restore_path` through `0061_project_catalogue_read` to -`0062_model_usage`, PostgreSQL ledger, public -provisioning and actual authorization catalogue adapter require final exact-head -evidence before readiness. -All six composed public profile-to-checker cases pass with real PostgreSQL, -local/MinIO ART and a controlled provider/image boundary: completion, retained -paid results after a lost image reply, concurrent callbacks, and explicit child -recovery after proven no-effect or fully settled provider failure. Database and -MinIO cleanup completed. Final independent reviews and full CI remain required -before readiness. These checks do not establish live provider or gVisor operation. - -Implementation review caught and repaired two runtime defects: inherited Compose -profiles unintentionally started the optional listener, and an empty prompt could -reserve budget before local validation rejected it. Profile publication now also -requires the exact registered POST image digest, with independent SQL checks of -canonical profile/budget specification hashes. Regressions cover each boundary; -the final frozen review must assess their composed behavior and current head. - -The retained-data upgrade into the model-usage migration exposed a PL/pgSQL parsing failure that -fresh baseline installation did not expose. A discriminating PostgreSQL probe -isolated the unparenthesized CASE expression in the ART binding guard; the -corrected upgrade passes with retained data and owned-database cleanup. Public -HTTP qualification also exposed strict JSON UUID decoding and a prepared -system-Operator scope mismatch. These require the real adapter/API proofs; the -ledger's controlled-authority tests alone cannot establish public usability. - -Composed execution also exposed strict Python decoding of the JSON prepared -ART authorization packet. The parser now validates that packet as JSON while -retaining strict integer validation. Both write and binding regressions reject -the original decoder. The composed fixture explicitly declares model evaluation -and supplies the canonical profile reader instead of attaching a model profile -to a static requirement; production policy validation remains unchanged. - -That composed path also exposed an inherited ART receipt trigger that required -a checker-run producer even for a paid-call receipt. The migration now admits -only the exact model-call attempt and request/response role while retaining the -existing producer guards. Fresh schema installation, the canonical ART custody -case and retained intermediate upgrade pass with the corrected trigger. - -Canonical storage regressions also require malformed or substituted commitment -facts to leave AUTH through its concealed unavailable outcome. Resource -validation now discards the prepared handle before returning that outcome; -changed bytes cannot reach provider I/O. The remaining ART cases cover mixed -binding lineage, rollback/concurrent replay, uncertain puts, revocation, -cancellation and size limits. The adapter composition uses existing owner ports -and passes the protected-base boundary check without adding private-edge debt. - -Operator recovery uses the existing authorized checker retry-source read to -discover bounded eligible paid-call candidates. Child preparation retains exact -current Task/Submission custody without requiring a live worker lease; actual -dispatch still requires a fresh current invocation. This closes the otherwise -unusable repair path after a terminal infrastructure failure. - -Current owner documentation replaces deny-only generated-output wording with -active paid-call ART custody, lists all nine model permission/action mappings and -describes stable effect records. Existing normalized checker outcomes and -true/false review-policy routing remain unchanged. - -The current security repair makes setup `known_failed` completion record the -required settlement authority and timestamp before the terminal transition. A -real PostgreSQL regression proves one provider dispatch, exact settled cost, -zero remaining reservation and immutable restart replay. Checker recovery rows -now retain canonical execution/effect facts, and the `0062_model_usage` guard -independently verifies exact project, Task, assignment, Submission, registry, -run, lease, generation, effect, service actor, action and resource digest. The -valid fresh retry successor and independent substitutions of every one of those -facts passed together with the setup settlement case (`2 passed` in 14.76s). - -The same repair closes repository metadata drift: project budget accounts and -grant-call bindings are explicitly classified natural/composite identities, -all MODEL foreign keys use the referenced native UUID representation, and the -ORM declares the budget configuration uniqueness required by the account's -composite foreign key. Native UUID metadata tests pass `5/5`; the complete -static identifier inventory passes `24/24`. The first combined canonical model -usage run installed every migration through `0061_model_usage` and emitted six passing -tests, but its final ART case exceeded the deliberately bounded 240-second child -limit. Cleanup completed and no full-run pass is claimed from that attempt; -that historical pre-rechain result does not qualify `0062_model_usage`; split -exact-head PostgreSQL replay and retained-data upgrade remain pending. - -Hosted configuration proof at `9a57c8831` passes 31 focused tests. An actual -parent Rust relay image built from `04bbac112` passed a controlled smoke with no -network, read-only input/root filesystem, only the invocation socket exposed, -the global grant concealed, and owned image/container cleanup confirmed. This -does not qualify hosted deployment or the composed gVisor runtime. The final -static API/lane run passes 59 tests, the CLI permission contract passes 23, and -the actual MCP permission payload passes 231; these are controlled local proofs, -not live provider or deployment evidence. - -Plan review found that existing ART output slots are empty and run/lease-bound; -the stable call evidence reservation above is required. It also required exact -profile selection/readiness checks rather than merely hashing opaque config. -The proposed new CheckerRun state was rejected as unnecessary: call-effect -recovery belongs to the durable slot while existing infrastructure outcomes stay -unchanged. +The canonical parent schema installs linearly through +`0060_audit_restore_path`, `0061_project_catalogue_read`, and +`0062_model_usage`; its measured public-schema digest is +`d14a06c088aae0e8876bebb637a7b3fb0c19344f8cc4fdfd31aa2c1a47348593`. +The migration and owner guard retain immutable prepare/dispatch/terminal +authority facts instead of reconstructing them from a later worker lease. A +real PostgreSQL selection passed 20/20 checker and setup authority cases, +including every foreign digest branch, a renewed legitimate checker grant, +successor-run attachment to one paid effect, known-failed settlement, and stale +or reused authorization rejection. Independent QA reran seven discriminating +authority/renewal cases and killed a mutant that restored latest-grant lookup. + +The controlled composed boundary has separate current evidence for its ordinary +and recovery paths. Four ordinary completion, retained-result and concurrent +callback cases passed with real PostgreSQL and MinIO. The concurrent callback +is held before first response publication so the second caller must recover the +known provider identifier; both replies use one effect, response receipt and +settlement. Proven no-effect and fully settled failure each passed their public +Operator recovery journey with exactly one additional pre-authorized dispatch, +while the original effect and evidence remain immutable. Database and MinIO +cleanup completed for each owned run. The setup handler separately proves that +two simultaneous resumptions share one durable paid effect and provider create; +the scripted setup coordinator convergence test is intentionally not represented +as paid-effect deduplication. + +Private request/response evidence uses the existing generated-output ART path. +Manual setup correction dispatch passed five public HTTP/PostgreSQL profile +selection, replay and denial cases: a created attempt remains pinned to profile +version 1 even after version 2 is published. Actual MinIO tests passed 11/11 +transport-timeout, outer-cancellation, precondition and malformed-observation +cases after the S3 adapter was made to join its single SDK body consumer before +returning; no source reads continue after timeout cleanup. + +The current hosted configuration passes 38/38 focused tests after reconciling +main's cached proof-image policy with the private model-proxy image identity. +An actual parent Rust relay image previously passed a controlled Docker smoke +with no network, read-only input/root filesystem, only the invocation socket +exposed, the global grant concealed, and owned image/container cleanup +confirmed. Static API/lane, CLI, MCP, Ruff, behavior ownership and structural +debt checks pass their affected selections. These are controlled local proofs, +not hosted deployment or composed gVisor qualification. + +Independent QA issued a PASS for the reviewed MODEL runtime atoms and the +discriminating PostgreSQL/composed callback tests. Attempts to obtain the +separate security-review verdict ended in platform review errors, so no security +sign-off is claimed. Current hosted CI, retained-data qualification on the +stacked migration, and remaining independent review gates must complete before +the draft is marked ready. No live provider call or paid spend occurred. The +historical setup spend remains unreconciled; deployment and any paid rehearsal +remain disabled until the Operator supplies the externally reconciled remaining +allowance within the existing US$10 aggregate ceiling. ## Reconciliation From bc5c68c0f79cc89063741aed3323696aac331680 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 12:28:59 +0100 Subject: [PATCH 097/115] docs(model-usage): record Smol capability boundary --- .commitrail/changes/checker-model-runtime.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.commitrail/changes/checker-model-runtime.md b/.commitrail/changes/checker-model-runtime.md index f5dcbb4ff..beef905dc 100644 --- a/.commitrail/changes/checker-model-runtime.md +++ b/.commitrail/changes/checker-model-runtime.md @@ -260,6 +260,12 @@ returning; no source reads continue after timeout cleanup. The current hosted configuration passes 38/38 focused tests after reconciling main's cached proof-image policy with the private model-proxy image identity. +Current main's Smol microVM engine was also reconciled without claiming a socket +capability it does not provide: its configuration rejects the host model relay, +and a model-entitled request on a Smol-only node closes as infrastructure +unavailable before VM admission. The combined checker-service unit selection +passes 44/44; model-enabled POST remains bound to the intended `runsc`/`gvisor` +deployment path, which still requires deployed qualification. An actual parent Rust relay image previously passed a controlled Docker smoke with no network, read-only input/root filesystem, only the invocation socket exposed, the global grant concealed, and owned image/container cleanup From 2cb516f6aa9d4f60683a1b905d24d5ddc40e19ad Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 12:41:07 +0100 Subject: [PATCH 098/115] test(model-usage): import profile reader from its owner --- .../checkers_model_calls/test_model_shared_setup_postgresql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py index c55ca04cf..433b19313 100644 --- a/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py +++ b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py @@ -4,6 +4,7 @@ import json from app.modules.model_usage.models import ModelUsageEffect +from app.modules.model_usage.profiles import ModelProfileReader from tests.checkers_model_calls.model_usage_pg_support import ( DatabaseFacts, model_database as _model_database, @@ -17,7 +18,6 @@ ModelCallUsage, ModelGenerationSettings, ModelProfileProvisioningFacts, - ModelProfileReader, ModelCallUnavailable, ModelUsageBudgetAccount, asyncio, From 78ee20655854c0000a620c0d3257980e5438d669 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 13:03:07 +0100 Subject: [PATCH 099/115] test(cli): publish correction runtime model profile --- .../test_guide_correction_public_api.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/cli/tests/integration/test_guide_correction_public_api.py b/cli/tests/integration/test_guide_correction_public_api.py index 1f6c60a15..9e49e1b3c 100644 --- a/cli/tests/integration/test_guide_correction_public_api.py +++ b/cli/tests/integration/test_guide_correction_public_api.py @@ -27,6 +27,10 @@ from test_public_self_service import _ready from app.core.identifiers import new_record_id from tests.project_create_fixtures import seed_historical_project +from tests.projects.guide_compilation.helpers import runtime_configuration +from tests.projects.guide_compilation.runtime_fixtures import ( + scripted_model_profile_spec, +) @pytest.mark.asyncio @@ -64,6 +68,9 @@ def token(actor): administrator, _ = await seed_review_actor( factory, None, role="access_administrator", scope="system" ) + model_operator, _ = await seed_review_actor( + factory, None, role="operator", scope="system" + ) foreign_id = new_record_id() async with factory() as session, session.begin(): await seed_historical_project( @@ -227,6 +234,18 @@ async def stored(expected, saved=None, dispatched=None): ) source = await direct.get(prefix + "/proposal", headers=headers) assert source.status_code == 200, source.text + profile = scripted_model_profile_spec( + command.project_id, runtime_configuration() + ) + published = await direct.post( + f"/api/v1/operations/projects/{command.project_id}/model-profiles", + headers={ + "Authorization": "Bearer " + token(model_operator), + "Idempotency-Key": str(uuid4()), + }, + json=profile.model_dump(mode="json"), + ) + assert published.status_code == 201, published.text body = { "target": source.json()["target"], "reason": " Clarify café evidence. ", From 345b90121fa4017df055980ad1358124e8647e77 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 13:11:54 +0100 Subject: [PATCH 100/115] test(model): repair shared lifecycle fixtures --- .../guide_compilation/finalization/pg_generations.py | 8 ++++++-- backend/tests/test_checker_output_storage.py | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/tests/projects/guide_compilation/finalization/pg_generations.py b/backend/tests/projects/guide_compilation/finalization/pg_generations.py index be02e1127..6abc14e99 100644 --- a/backend/tests/projects/guide_compilation/finalization/pg_generations.py +++ b/backend/tests/projects/guide_compilation/finalization/pg_generations.py @@ -10,7 +10,7 @@ from app.modules.projects.api.guide_proposals import GuideProposalCorrection from app.modules.projects.guide_compilation.proposal_service import GuideProposalService -from ..helpers import context +from ..helpers import context, manager_runtime_configuration from ..proposals.pg_support import ProposalAuthority, read_package, request_corrected_attempt from .pg_support import finalize @@ -63,7 +63,11 @@ async def second_generation(factory, values, first): setup_generation=correction.successor_setup_generation, ) ) - compilation_context = context(values, generation=2).model_copy( + compilation_context = context( + values, + generation=2, + configuration=manager_runtime_configuration(), + ).model_copy( update={ "setup_run_id": correction.successor_setup_run_id, "setup_generation": correction.successor_setup_generation, diff --git a/backend/tests/test_checker_output_storage.py b/backend/tests/test_checker_output_storage.py index 774a58d59..e48de7c30 100644 --- a/backend/tests/test_checker_output_storage.py +++ b/backend/tests/test_checker_output_storage.py @@ -813,6 +813,10 @@ async def test_artifact_binding_truncate_custody_blocks_direct_and_cascade_delet ) ) for table, constraint in ( + ( + "model_usage_effects", + "fk_model_usage_effects_response_binding", + ), ( "submission_binding_receipts", "fk_submission_binding_receipts_binding_id_artifact_bindings", From e667b3f1b9d39af4f2436ebfded7a15a23007134 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 13:15:36 +0100 Subject: [PATCH 101/115] test(model): align shared fixtures with model custody --- backend/tests/authorization/catalogue_fixtures.py | 1 + .../projects/post_policy/test_public_recovery.py | 12 ++++++++++++ ...tifact_provider_activation_terminal_postgresql.py | 3 ++- backend/tests/test_checker_output_storage.py | 4 ++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/backend/tests/authorization/catalogue_fixtures.py b/backend/tests/authorization/catalogue_fixtures.py index a59e020a0..8393c71e5 100644 --- a/backend/tests/authorization/catalogue_fixtures.py +++ b/backend/tests/authorization/catalogue_fixtures.py @@ -580,6 +580,7 @@ AUDIT_ALLOWED_ACTION_VALUES = { + "project.catalogue.list", "checker.model_call.prepare", "checker.model_call.dispatch", "checker.model_call.recover", diff --git a/backend/tests/projects/post_policy/test_public_recovery.py b/backend/tests/projects/post_policy/test_public_recovery.py index 46e2853ce..5157ec5c7 100644 --- a/backend/tests/projects/post_policy/test_public_recovery.py +++ b/backend/tests/projects/post_policy/test_public_recovery.py @@ -44,8 +44,10 @@ async def test_lost_post_policy_correction_discovered_and_dispatched_by_new_mana isolated_database_env, monkeypatch, post_policy_worker, ): from app.core import project_agents + from app.core.config import get_settings from app.modules.projects import setup_queue from tests.projects.guide_compilation.runtime_fixtures import ( + publish_scripted_model_profile, scripted_runtime_configuration, ) @@ -58,6 +60,16 @@ def enqueue(**values): async with public_approved_case(isolated_database_env, monkeypatch) as ( factory, command, creator, grant, approval_id, _, ): + configuration = scripted_runtime_configuration( + get_settings(), adapter_profile_builder=lambda value: value + ) + operator, _ = await seed_review_actor( + factory, None, role="operator", scope="system" + ) + async with proposal_client(factory, operator) as operator_client: + await publish_scripted_model_profile( + operator_client, command.project_id, configuration + ) assert deliver(post_policy_worker, approval_id)["status"] == "policy_draft_ready" path = proposal_path(command) async with proposal_client(factory, creator) as client: diff --git a/backend/tests/test_artifact_provider_activation_terminal_postgresql.py b/backend/tests/test_artifact_provider_activation_terminal_postgresql.py index 7de1d9772..004fbff29 100644 --- a/backend/tests/test_artifact_provider_activation_terminal_postgresql.py +++ b/backend/tests/test_artifact_provider_activation_terminal_postgresql.py @@ -493,7 +493,8 @@ async def observe(_commitment, *, operation_evidence): assert receipt.observed_sha256 is None assert receipt.observed_byte_count is None assert receipt.execution_generation == attempt.execution_generation - assert checker_run_id == attempt.checker_run_id + assert attempt.checker_run_id is None + assert attempt.model_call_attempt_id is not None assert orchestrator._require_terminal_activation.await_count >= 2 assert all( call.args == (activation_id,) diff --git a/backend/tests/test_checker_output_storage.py b/backend/tests/test_checker_output_storage.py index e48de7c30..871692274 100644 --- a/backend/tests/test_checker_output_storage.py +++ b/backend/tests/test_checker_output_storage.py @@ -813,6 +813,10 @@ async def test_artifact_binding_truncate_custody_blocks_direct_and_cascade_delet ) ) for table, constraint in ( + ( + "model_usage_effects", + "fk_model_usage_effects_request_binding", + ), ( "model_usage_effects", "fk_model_usage_effects_response_binding", From c9d3a2336e088ff589c96f694ebd3334a6107124 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 13:18:52 +0100 Subject: [PATCH 102/115] test(projects): preserve metered correction semantics --- .../guide_compilation/proposals/pg_support.py | 20 +++++++++--- .../test_live_cutover_postgresql.py | 2 +- .../projects/post_policy/test_replacement.py | 31 +++++++++++++++++-- ...provider_activation_terminal_postgresql.py | 5 +-- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/backend/tests/projects/guide_compilation/proposals/pg_support.py b/backend/tests/projects/guide_compilation/proposals/pg_support.py index cd72922c4..e7bd6af22 100644 --- a/backend/tests/projects/guide_compilation/proposals/pg_support.py +++ b/backend/tests/projects/guide_compilation/proposals/pg_support.py @@ -238,7 +238,9 @@ async def read_package(factory, command, actor, grant_id): raise exc from exc.__context__ -async def request_corrected_attempt(factory, actor, correction, *, capabilities=None): +async def request_corrected_attempt( + factory, actor, correction, *, capabilities=None, configuration=None +): """Enter the existing production human request port; no provider is invoked.""" from app.adapters.artifacts import guide_document_manifest_port from app.modules.authorization.guide_compilation import ( @@ -276,7 +278,7 @@ async def request_corrected_attempt(factory, actor, correction, *, capabilities= request_inputs=CompilationRequestInputs( guide_document_manifest_port(session), capabilities or capability_projection(), - manager_runtime_configuration(), + configuration or manager_runtime_configuration(), ), ) return await service.request_correction( @@ -284,7 +286,9 @@ async def request_corrected_attempt(factory, actor, correction, *, capabilities= ) -async def finalize_corrected_attempt(factory, values, actor, correction, *, outcome=None): +async def finalize_corrected_attempt( + factory, values, actor, correction, *, outcome=None, configuration=None +): """Run a scripted result through actual execution, projections and finalization.""" from app.adapters.artifacts import guide_document_manifest_port from app.adapters.auth import ( @@ -307,10 +311,16 @@ async def finalize_corrected_attempt(factory, values, actor, correction, *, outc async with factory() as session, session.begin(): capabilities = await external_checker_capability_projection(session) - await request_corrected_attempt(factory, actor, correction, capabilities=capabilities) + configuration = configuration or manager_runtime_configuration() + await request_corrected_attempt( + factory, + actor, + correction, + capabilities=capabilities, + configuration=configuration, + ) async with factory() as session: setup = await session.get(ProjectSetupRun, str(correction.successor_setup_run_id)) - configuration = manager_runtime_configuration() runtime = ScriptedGuideRuntime(configuration) if outcome is not None: runtime.outcome = outcome diff --git a/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py b/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py index f6202a205..1cac0eb5f 100644 --- a/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py @@ -339,7 +339,7 @@ async def test_invalid_or_uncertain_attempt_reports_durable_diagnostics_without_ first = await coordinator.run(delivery) replay = await coordinator.run(delivery) assert first == replay - assert runtime.calls == 1 + assert runtime.calls == (2 if outcome == "uncertain" else 1) assert first["status"] == ( "compilation_invalid_terminal" if outcome == "invalid" else "provider_outcome_unresolved" ) diff --git a/backend/tests/projects/post_policy/test_replacement.py b/backend/tests/projects/post_policy/test_replacement.py index 55457ab48..dff30afce 100644 --- a/backend/tests/projects/post_policy/test_replacement.py +++ b/backend/tests/projects/post_policy/test_replacement.py @@ -8,10 +8,20 @@ from app.core.identifiers import new_record_id from app.core.hashing import canonical_json_hash +from app.core.config import get_settings from app.modules.projects.api.guide_proposals import GuideProposalApproval, GuideProposalSelection from app.modules.projects.api.post_policy import PostPolicyApproval, PostPolicyCorrection, PostPolicyDerive from tests.projects.guide_compilation.helpers import service_actor -from tests.projects.guide_compilation.proposals.pg_support import proposal_case, read_package, finalize_corrected_attempt +from tests.projects.guide_compilation.proposals.pg_support import ( + finalize_corrected_attempt, + proposal_case, + read_package, + seed_review_actor, +) +from tests.projects.guide_compilation.runtime_fixtures import ( + publish_scripted_model_profile, + scripted_runtime_configuration, +) from tests.projects.guide_compilation.proposals.test_postgresql import approve from tests.projects.guide_compilation.proposals.public_support import proposal_client, proposal_path from .pg_support import prepare_post_policy, operate @@ -20,6 +30,21 @@ @pytest.mark.parametrize('origin', ['post_policy', 'unified']) async def test_corrected_generation_replaces_policy_and_retains_original_receipts(clean_postgres_database, origin): async with proposal_case(clean_postgres_database) as (values, factory, command, actor, grant): + operator, _ = await seed_review_actor(factory, None, role="operator", scope="system") + configuration = scripted_runtime_configuration( + get_settings(), adapter_profile_builder=lambda value: value + ) + async with proposal_client(factory, operator) as operator_client: + profile = await publish_scripted_model_profile( + operator_client, command.project_id, configuration + ) + configuration = configuration.model_copy( + update={ + "model_profile_id": profile["profile_id"], + "model_profile_version": profile["spec"]["profile_version"], + "model_profile_digest": profile["profile_digest"], + } + ) setup = service_actor(values) derive, first = await prepare_post_policy(factory, command, actor, grant, setup) approval = PostPolicyApproval(target=first.target, idempotency_key=uuid4()) @@ -33,7 +58,9 @@ async def test_corrected_generation_replaces_policy_and_retains_original_receipt from tests.projects.guide_compilation.proposals.test_postgresql import correct successor = await correct(factory, command, actor, grant, GuideProposalCorrection( target=first.target.proposal, idempotency_key=uuid4(), reason='Reconsider the complete guide proposal')) - next_command = await finalize_corrected_attempt(factory, values, actor, successor) + next_command = await finalize_corrected_attempt( + factory, values, actor, successor, configuration=configuration + ) package = await read_package(factory, next_command, actor, grant) upstream = await approve(factory, next_command, actor, grant, GuideProposalApproval( target=package.target, idempotency_key=uuid4(), diff --git a/backend/tests/test_artifact_provider_activation_terminal_postgresql.py b/backend/tests/test_artifact_provider_activation_terminal_postgresql.py index 004fbff29..b4a4d90e0 100644 --- a/backend/tests/test_artifact_provider_activation_terminal_postgresql.py +++ b/backend/tests/test_artifact_provider_activation_terminal_postgresql.py @@ -345,11 +345,12 @@ async def test_checker_output_shared_put_and_verification_lifecycle( assert attempt is not None and receipt is not None and job is not None job_id = UUID(job.id) assert attempt.producer_request_type == "checker_output" - assert (attempt.project_id, attempt.task_id, attempt.checker_run_id) == ( + assert (attempt.project_id, attempt.task_id) == ( project_id, task_id, - checker_run_id, ) + assert attempt.checker_run_id is None + assert attempt.model_call_attempt_id is not None assert receipt.checker_run_id == checker_run_id with pytest.raises(DBAPIError): await session.execute( From cf41b966e1f5b8930ee18395b3335138638f35f5 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 13:21:42 +0100 Subject: [PATCH 103/115] test(artifacts): assert current model output lineage --- .../test_artifact_provider_activation_terminal_postgresql.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_artifact_provider_activation_terminal_postgresql.py b/backend/tests/test_artifact_provider_activation_terminal_postgresql.py index b4a4d90e0..96c396945 100644 --- a/backend/tests/test_artifact_provider_activation_terminal_postgresql.py +++ b/backend/tests/test_artifact_provider_activation_terminal_postgresql.py @@ -351,7 +351,8 @@ async def test_checker_output_shared_put_and_verification_lifecycle( ) assert attempt.checker_run_id is None assert attempt.model_call_attempt_id is not None - assert receipt.checker_run_id == checker_run_id + assert receipt.checker_run_id is None + assert receipt.model_call_attempt_id == attempt.model_call_attempt_id with pytest.raises(DBAPIError): await session.execute( text( From 68b14fb9c1d30881003c63b1ebf8d9cb453e7e0e Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 13:26:08 +0100 Subject: [PATCH 104/115] test(projects): configure metered correction fixtures --- .../guide_compilation/test_live_cutover_postgresql.py | 2 ++ backend/tests/projects/post_policy/test_public_recovery.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py b/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py index 1cac0eb5f..25112ffd5 100644 --- a/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py @@ -339,6 +339,8 @@ async def test_invalid_or_uncertain_attempt_reports_durable_diagnostics_without_ first = await coordinator.run(delivery) replay = await coordinator.run(delivery) assert first == replay + # The scripted runtime re-enters once for uncertain delivery. Production + # model custody separately prevents repeating the same paid provider effect. assert runtime.calls == (2 if outcome == "uncertain" else 1) assert first["status"] == ( "compilation_invalid_terminal" if outcome == "invalid" else "provider_outcome_unresolved" diff --git a/backend/tests/projects/post_policy/test_public_recovery.py b/backend/tests/projects/post_policy/test_public_recovery.py index 5157ec5c7..f975aeb10 100644 --- a/backend/tests/projects/post_policy/test_public_recovery.py +++ b/backend/tests/projects/post_policy/test_public_recovery.py @@ -52,6 +52,11 @@ async def test_lost_post_policy_correction_discovered_and_dispatched_by_new_mana ) monkeypatch.setattr(project_agents, "project_guide_runtime_configuration", scripted_runtime_configuration) + monkeypatch.setenv( + "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + ) + get_settings.cache_clear() dispatched = [] def enqueue(**values): dispatched.append(values) From 2d1d696fce0799c3816a91b3ca5b07ae03b02156 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 13:27:16 +0100 Subject: [PATCH 105/115] test(projects): configure correction profile publication --- backend/tests/projects/post_policy/test_replacement.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/tests/projects/post_policy/test_replacement.py b/backend/tests/projects/post_policy/test_replacement.py index dff30afce..c20a845f5 100644 --- a/backend/tests/projects/post_policy/test_replacement.py +++ b/backend/tests/projects/post_policy/test_replacement.py @@ -28,7 +28,14 @@ @pytest.mark.parametrize('origin', ['post_policy', 'unified']) -async def test_corrected_generation_replaces_policy_and_retains_original_receipts(clean_postgres_database, origin): +async def test_corrected_generation_replaces_policy_and_retains_original_receipts( + clean_postgres_database, monkeypatch, origin +): + monkeypatch.setenv( + "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + ) + get_settings.cache_clear() async with proposal_case(clean_postgres_database) as (values, factory, command, actor, grant): operator, _ = await seed_review_actor(factory, None, role="operator", scope="system") configuration = scripted_runtime_configuration( From bfea480c0351e73750df31a96ef646419590b9e9 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 13:29:22 +0100 Subject: [PATCH 106/115] test(projects): preserve typed profile selector --- backend/tests/projects/post_policy/test_replacement.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/projects/post_policy/test_replacement.py b/backend/tests/projects/post_policy/test_replacement.py index c20a845f5..59b78a4e9 100644 --- a/backend/tests/projects/post_policy/test_replacement.py +++ b/backend/tests/projects/post_policy/test_replacement.py @@ -1,6 +1,6 @@ """A corrected unified generation replaces one policy chain without rewriting evidence.""" -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest from sqlalchemy import text @@ -47,7 +47,7 @@ async def test_corrected_generation_replaces_policy_and_retains_original_receipt ) configuration = configuration.model_copy( update={ - "model_profile_id": profile["profile_id"], + "model_profile_id": UUID(profile["profile_id"]), "model_profile_version": profile["spec"]["profile_version"], "model_profile_digest": profile["profile_digest"], } From bb77a2e8ccd80b37a855c7d863eab187c5654e44 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 13:42:12 +0100 Subject: [PATCH 107/115] test(cli): bind correction profile to API runtime --- .../test_guide_correction_public_api.py | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/cli/tests/integration/test_guide_correction_public_api.py b/cli/tests/integration/test_guide_correction_public_api.py index 9e49e1b3c..7247ea1ae 100644 --- a/cli/tests/integration/test_guide_correction_public_api.py +++ b/cli/tests/integration/test_guide_correction_public_api.py @@ -2,6 +2,7 @@ import asyncio from copy import deepcopy +from functools import partial import json import subprocess import sys @@ -25,9 +26,11 @@ pagination_cursor_hmac_secret as pagination_cursor_hmac_secret, ) from test_public_self_service import _ready +from app.adapters.project_agents import build_project_guide_adapter_profile +from app.core.config import get_settings from app.core.identifiers import new_record_id +from app.core.project_agents import project_guide_runtime_configuration from tests.project_create_fixtures import seed_historical_project -from tests.projects.guide_compilation.helpers import runtime_configuration from tests.projects.guide_compilation.runtime_fixtures import ( scripted_model_profile_spec, ) @@ -35,7 +38,7 @@ @pytest.mark.asyncio async def test_guide_correction_public_save_dispatch_replay_and_authority( - clean_postgres_database, tmp_path, cli + clean_postgres_database, tmp_path, cli, monkeypatch, # noqa: F811 ): env = api_environment() env["WORKSTREAM_E2E_FLOW_ISSUER"] = "https://identity.flowresearch.tech" @@ -234,8 +237,24 @@ async def stored(expected, saved=None, dispatched=None): ) source = await direct.get(prefix + "/proposal", headers=headers) assert source.status_code == 200, source.text + with monkeypatch.context() as runtime_environment: + for name, value in env.items(): + if name.startswith("WORKSTREAM_"): + runtime_environment.setenv(name, value) + get_settings.cache_clear() + try: + runtime_settings = get_settings() + configuration = project_guide_runtime_configuration( + runtime_settings, + adapter_profile_builder=partial( + build_project_guide_adapter_profile, + runtime_settings.project_agent_runtime, + ), + ) + finally: + get_settings.cache_clear() profile = scripted_model_profile_spec( - command.project_id, runtime_configuration() + command.project_id, configuration ) published = await direct.post( f"/api/v1/operations/projects/{command.project_id}/model-profiles", From f0027e6f085db0c20fe4ecec999531792ad800bb Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Sat, 24 Oct 2026 13:46:13 +0100 Subject: [PATCH 108/115] style(cli): format correction runtime fixture --- .../integration/test_guide_correction_public_api.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cli/tests/integration/test_guide_correction_public_api.py b/cli/tests/integration/test_guide_correction_public_api.py index 7247ea1ae..1479a13d2 100644 --- a/cli/tests/integration/test_guide_correction_public_api.py +++ b/cli/tests/integration/test_guide_correction_public_api.py @@ -38,7 +38,10 @@ @pytest.mark.asyncio async def test_guide_correction_public_save_dispatch_replay_and_authority( - clean_postgres_database, tmp_path, cli, monkeypatch, # noqa: F811 + clean_postgres_database, + tmp_path, + cli, + monkeypatch, # noqa: F811 ): env = api_environment() env["WORKSTREAM_E2E_FLOW_ISSUER"] = "https://identity.flowresearch.tech" @@ -253,9 +256,7 @@ async def stored(expected, saved=None, dispatched=None): ) finally: get_settings.cache_clear() - profile = scripted_model_profile_spec( - command.project_id, configuration - ) + profile = scripted_model_profile_spec(command.project_id, configuration) published = await direct.post( f"/api/v1/operations/projects/{command.project_id}/model-profiles", headers={ From 2f8f554494471b596c2bc98f62d4fa5aa4c56106 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 24 Oct 2026 15:19:25 +0100 Subject: [PATCH 109/115] test(api): provision scripted setup model profile --- backend/scripts/api_contract_e2e.py | 6 +++ backend/scripts/guide_compilation_e2e.py | 64 ++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 0603e4f04..fbd2e18fb 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1544,6 +1544,12 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: idempotency_key=str(uuid4()), ) assert patched_guide["change_summary"] == "Patched before activation through real API" + from guide_compilation_e2e import publish_scripted_model_profile + published_model_profile = await publish_scripted_model_profile( + client, manager_token, project["id"], + ) + assert published_model_profile["project_id"] == project["id"] + assert published_model_profile["spec"]["profile_version"] == 1 await exercise_guide_setup_contract( client, manager_token, diff --git a/backend/scripts/guide_compilation_e2e.py b/backend/scripts/guide_compilation_e2e.py index 78bf17017..046e04b1e 100644 --- a/backend/scripts/guide_compilation_e2e.py +++ b/backend/scripts/guide_compilation_e2e.py @@ -4,6 +4,7 @@ from hashlib import sha256 from pathlib import Path from unittest.mock import patch +from uuid import UUID, uuid4 from app.adapters.guide_document_tools import ( @@ -15,6 +16,8 @@ ) from app.adapters.project_agents import build_project_guide_adapter_profile from app.core.config import get_settings +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id from app.core.project_agents import project_guide_runtime_configuration from app.interfaces.external_services import ( ExternalServiceAdapterFactory, @@ -31,6 +34,11 @@ from app.modules.projects.api.guide_compilation import ( ProjectGuideCompilationDelivery, ) +from app.modules.model_usage.api import ( + ModelGenerationSettings, + ModelPricing, + ProjectModelProfileSpec, +) class E2EProjectGuideRuntime: @@ -145,6 +153,62 @@ def create(configuration): return create +async def publish_scripted_model_profile( + client, operator_token: str, project_id: str, +) -> dict: + """Publish the exact finite profile required by the scripted setup runtime.""" + settings = get_settings() + configuration = project_guide_runtime_configuration( + settings, + adapter_profile_builder=partial( + build_project_guide_adapter_profile, settings.project_agent_runtime + ), + ) + maximum_calls = configuration.maximum_turns + spec = ProjectModelProfileSpec( + project_id=UUID(project_id), + profile_version=1, + provider=configuration.model_provider, + api=configuration.model_api, + model_snapshot=configuration.model, + settings=ModelGenerationSettings( + reasoning_effort="medium", text_verbosity="low", + ), + maximum_request_bytes=1_048_576, + maximum_output_bytes=131_072, + maximum_response_bytes=1_048_576, + maximum_input_tokens=1_000_000, + maximum_output_tokens=16_384, + maximum_calls=maximum_calls, + maximum_total_request_bytes=1_048_576 * maximum_calls, + maximum_total_response_bytes=1_048_576 * maximum_calls, + maximum_total_input_tokens=1_000_000 * maximum_calls, + maximum_total_output_tokens=16_384 * maximum_calls, + allowed_tools=("read_document", "search_document"), + maximum_tool_calls=configuration.maximum_tool_calls, + maximum_tool_output_bytes=configuration.maximum_tool_output_characters * 4, + deadline_ms=configuration.timeout_seconds * 1000, + pricing=ModelPricing( + pricing_policy_id=new_record_id(), + pricing_policy_version=1, + pricing_policy_digest=canonical_json_hash( + {"contract": "real_api_scripted_model_pricing", "version": 1} + ), + uncached_input_nanodollars_per_token=1, + cached_input_nanodollars_per_token=1, + output_nanodollars_per_token=1, + ), + ) + response = await client.post( + f"/api/v1/operations/projects/{project_id}/model-profiles", + headers={"Authorization": f"Bearer {operator_token}", + "Idempotency-Key": str(uuid4())}, + json=spec.model_dump(mode="json"), + ) + assert response.status_code == 201, response.text + return response.json() + + async def compile_live_guide(delivery: ProjectGuideCompilationDelivery) -> dict: """Drive the sole worker delivery and prove its finalization replays without inference.""" from app.workers import project_setup as worker From 7d87f7090854d6529916de3d6b2e0e74fe50a25b Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 24 Oct 2026 15:21:09 +0100 Subject: [PATCH 110/115] test(api): admit typed setup model session --- backend/scripts/api_contract_e2e.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index fbd2e18fb..5b21508fd 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -202,6 +202,14 @@ def api_environment() -> dict[str, str]: env["WORKSTREAM_FLOW_AUTH_AUDIENCE"] = flow_audience env["WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET"] = flow_secret env["WORKSTREAM_PROJECT_AGENT_MODEL"] = "api-contract-fixture-model" + model_socket = Path(env.get("RUNNER_TEMP", "/tmp")) / ( + f"workstream-api-contract-model-{os.getpid()}.sock" + ) + if os.path.lexists(model_socket): + raise RuntimeError("scripted model socket path is not fresh") + # The deterministic runtime does not call the capability. A concrete absent + # path still proves typed profile/session admission without a provider bypass. + env["WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET"] = str(model_socket) env["WORKSTREAM_CELERY_TASK_ALWAYS_EAGER"] = "true" env["WORKSTREAM_CELERY_BROKER_URL"] = "memory://" env["WORKSTREAM_CELERY_RESULT_BACKEND_URL"] = "cache+memory://" From eafd9d49dc78722a872f46e1637762c19cfc371e Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 24 Oct 2026 15:29:53 +0100 Subject: [PATCH 111/115] test(api): grant setup profile operator authority --- backend/scripts/api_contract_e2e.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 5b21508fd..69da0637a 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1552,9 +1552,34 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: idempotency_key=str(uuid4()), ) assert patched_guide["change_summary"] == "Patched before activation through real API" + setup_operator_token = issue_flow_token( + f"real-api-setup-operator-{run_id}", + [], + issuer=flow_issuer, + audience=flow_audience, + secret=flow_secret, + ) + setup_operator = await request_json( + client, "GET", "/api/v1/actors/me", setup_operator_token, + ) + await request_json( + client, + "POST", + "/api/v1/admin-role-grants", + manager_token, + { + "target_actor_profile_id": setup_operator["actor_profile_id"], + "role": "operator", + "scope_type": "system", + "scope_project_id": None, + "reason": "Provision the scripted setup model profile", + }, + 201, + idempotency_key=str(uuid4()), + ) from guide_compilation_e2e import publish_scripted_model_profile published_model_profile = await publish_scripted_model_profile( - client, manager_token, project["id"], + client, setup_operator_token, project["id"], ) assert published_model_profile["project_id"] == project["id"] assert published_model_profile["spec"]["profile_version"] == 1 From 36c0a3eec5249ac9b9bad7c2b110d9f0a16ef561 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 24 Oct 2026 15:31:52 +0100 Subject: [PATCH 112/115] test(api): assert published profile subject --- backend/scripts/api_contract_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 69da0637a..5e64fe9df 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1581,7 +1581,7 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: published_model_profile = await publish_scripted_model_profile( client, setup_operator_token, project["id"], ) - assert published_model_profile["project_id"] == project["id"] + assert published_model_profile["spec"]["project_id"] == project["id"] assert published_model_profile["spec"]["profile_version"] == 1 await exercise_guide_setup_contract( client, From b9ca550be974787ec01bfb4c158d1ed96555d3ce Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 24 Oct 2026 15:53:43 +0100 Subject: [PATCH 113/115] test(api): configure provider-free artifact custody --- backend/scripts/api_contract_e2e.py | 25 +++++++++++++++++----- backend/tests/test_api_contract_e2e.py | 29 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 5e64fe9df..b48376b46 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -202,9 +202,9 @@ def api_environment() -> dict[str, str]: env["WORKSTREAM_FLOW_AUTH_AUDIENCE"] = flow_audience env["WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET"] = flow_secret env["WORKSTREAM_PROJECT_AGENT_MODEL"] = "api-contract-fixture-model" - model_socket = Path(env.get("RUNNER_TEMP", "/tmp")) / ( - f"workstream-api-contract-model-{os.getpid()}.sock" - ) + runtime_token = f"{os.getpid()}-{uuid4().hex}" + runner_temp = Path(env.get("RUNNER_TEMP", "/tmp")) + model_socket = runner_temp / f"workstream-api-contract-model-{runtime_token}.sock" if os.path.lexists(model_socket): raise RuntimeError("scripted model socket path is not fresh") # The deterministic runtime does not call the capability. A concrete absent @@ -217,12 +217,11 @@ def api_environment() -> dict[str, str]: minio_bucket = env.get("WORKSTREAM_TEST_MINIO_BUCKET") minio_prefix = env.get("WORKSTREAM_TEST_MINIO_PREFIX") if minio_endpoint and minio_bucket and minio_prefix: - scratch_parent = Path(env.get("RUNNER_TEMP", "/tmp")) env.update( { "WORKSTREAM_ARTIFACT_STORE_BACKEND": "s3_compatible", "WORKSTREAM_ARTIFACT_SCRATCH_ROOT": str( - scratch_parent / "workstream-api-contract-scratch" + runner_temp / "workstream-api-contract-scratch" ), "WORKSTREAM_ARTIFACT_S3_PROVIDER_PROFILE": "minio", "WORKSTREAM_ARTIFACT_S3_REGION": "us-east-1", @@ -239,6 +238,22 @@ def api_environment() -> dict[str, str]: "WORKSTREAM_ARTIFACT_ADMISSION_DEPLOYMENT_MAXIMUM_BYTES": "67108864", } ) + elif env.get("WORKSTREAM_ARTIFACT_STORE_BACKEND", "disabled") == "disabled": + env.update( + { + "WORKSTREAM_ARTIFACT_STORE_BACKEND": "local", + "WORKSTREAM_ARTIFACT_LOCAL_ROOT": str( + runner_temp / f"workstream-api-contract-artifacts-{runtime_token}" + ), + "WORKSTREAM_ARTIFACT_SCRATCH_ROOT": str( + runner_temp / f"workstream-api-contract-scratch-{runtime_token}" + ), + "WORKSTREAM_ARTIFACT_ADMISSION_TASK_MAXIMUM_BYTES": "67108864", + "WORKSTREAM_ARTIFACT_ADMISSION_PRODUCER_MAXIMUM_BYTES": "67108864", + "WORKSTREAM_ARTIFACT_ADMISSION_PROJECT_MAXIMUM_BYTES": "67108864", + "WORKSTREAM_ARTIFACT_ADMISSION_DEPLOYMENT_MAXIMUM_BYTES": "67108864", + } + ) env.setdefault( "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", base64.b64encode(os.urandom(32)).decode("ascii"), diff --git a/backend/tests/test_api_contract_e2e.py b/backend/tests/test_api_contract_e2e.py index 09e022e6f..710a65186 100644 --- a/backend/tests/test_api_contract_e2e.py +++ b/backend/tests/test_api_contract_e2e.py @@ -191,6 +191,35 @@ def test_api_contract_uses_runner_owned_minio_namespace( assert settings.artifact_s3_bucket == "workstream-ci-isolated-012345abcdef" +def test_api_contract_enables_local_artifacts_for_typed_model_sessions( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Provider-free API consumers still admit the typed setup-model capability.""" + api_contract = MODULES[0] + monkeypatch.setenv("RUNNER_TEMP", str(tmp_path)) + for key in ( + "WORKSTREAM_TEST_MINIO_ENDPOINT", + "WORKSTREAM_TEST_MINIO_BUCKET", + "WORKSTREAM_TEST_MINIO_PREFIX", + "WORKSTREAM_ARTIFACT_STORE_BACKEND", + ): + monkeypatch.delenv(key, raising=False) + + env = api_contract.api_environment() + + assert env["WORKSTREAM_ARTIFACT_STORE_BACKEND"] == "local" + assert env["WORKSTREAM_ARTIFACT_LOCAL_ROOT"] != env["WORKSTREAM_ARTIFACT_SCRATCH_ROOT"] + assert env["WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET"].startswith(str(tmp_path) + "/") + for key, value in env.items(): + if key.startswith("WORKSTREAM_ARTIFACT_") or key == ( + "WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET" + ): + monkeypatch.setenv(key, value) + settings = Settings(_env_file=None, environment=env["WORKSTREAM_ENVIRONMENT"]) + assert settings.artifact_store_backend == "local" + assert settings.checker_model_proxy_socket is not None + + def test_real_api_drill_provisions_exact_guide_artifact_pipeline_services() -> None: """The fan-in owns every fixed principal required before guide setup can run.""" api_contract = MODULES[0] From 126a3a61f5600d7ecf8109f0a9235950e89bc9af Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 24 Oct 2026 15:59:47 +0100 Subject: [PATCH 114/115] test(api): scope model session to setup drill --- backend/scripts/api_contract_e2e.py | 56 ++++++++++++++------------ backend/tests/test_api_contract_e2e.py | 7 +++- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index b48376b46..b223f7bc7 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -202,14 +202,6 @@ def api_environment() -> dict[str, str]: env["WORKSTREAM_FLOW_AUTH_AUDIENCE"] = flow_audience env["WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET"] = flow_secret env["WORKSTREAM_PROJECT_AGENT_MODEL"] = "api-contract-fixture-model" - runtime_token = f"{os.getpid()}-{uuid4().hex}" - runner_temp = Path(env.get("RUNNER_TEMP", "/tmp")) - model_socket = runner_temp / f"workstream-api-contract-model-{runtime_token}.sock" - if os.path.lexists(model_socket): - raise RuntimeError("scripted model socket path is not fresh") - # The deterministic runtime does not call the capability. A concrete absent - # path still proves typed profile/session admission without a provider bypass. - env["WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET"] = str(model_socket) env["WORKSTREAM_CELERY_TASK_ALWAYS_EAGER"] = "true" env["WORKSTREAM_CELERY_BROKER_URL"] = "memory://" env["WORKSTREAM_CELERY_RESULT_BACKEND_URL"] = "cache+memory://" @@ -217,11 +209,12 @@ def api_environment() -> dict[str, str]: minio_bucket = env.get("WORKSTREAM_TEST_MINIO_BUCKET") minio_prefix = env.get("WORKSTREAM_TEST_MINIO_PREFIX") if minio_endpoint and minio_bucket and minio_prefix: + scratch_parent = Path(env.get("RUNNER_TEMP", "/tmp")) env.update( { "WORKSTREAM_ARTIFACT_STORE_BACKEND": "s3_compatible", "WORKSTREAM_ARTIFACT_SCRATCH_ROOT": str( - runner_temp / "workstream-api-contract-scratch" + scratch_parent / "workstream-api-contract-scratch" ), "WORKSTREAM_ARTIFACT_S3_PROVIDER_PROFILE": "minio", "WORKSTREAM_ARTIFACT_S3_REGION": "us-east-1", @@ -238,22 +231,6 @@ def api_environment() -> dict[str, str]: "WORKSTREAM_ARTIFACT_ADMISSION_DEPLOYMENT_MAXIMUM_BYTES": "67108864", } ) - elif env.get("WORKSTREAM_ARTIFACT_STORE_BACKEND", "disabled") == "disabled": - env.update( - { - "WORKSTREAM_ARTIFACT_STORE_BACKEND": "local", - "WORKSTREAM_ARTIFACT_LOCAL_ROOT": str( - runner_temp / f"workstream-api-contract-artifacts-{runtime_token}" - ), - "WORKSTREAM_ARTIFACT_SCRATCH_ROOT": str( - runner_temp / f"workstream-api-contract-scratch-{runtime_token}" - ), - "WORKSTREAM_ARTIFACT_ADMISSION_TASK_MAXIMUM_BYTES": "67108864", - "WORKSTREAM_ARTIFACT_ADMISSION_PRODUCER_MAXIMUM_BYTES": "67108864", - "WORKSTREAM_ARTIFACT_ADMISSION_PROJECT_MAXIMUM_BYTES": "67108864", - "WORKSTREAM_ARTIFACT_ADMISSION_DEPLOYMENT_MAXIMUM_BYTES": "67108864", - } - ) env.setdefault( "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", base64.b64encode(os.urandom(32)).decode("ascii"), @@ -266,6 +243,34 @@ def api_environment() -> dict[str, str]: return env +def configure_scripted_model_session_environment(env: dict[str, str]) -> None: + """Provision the private test paths required only by the full setup drill.""" + runtime_token = f"{os.getpid()}-{uuid4().hex}" + runner_temp = Path(env.get("RUNNER_TEMP", "/tmp")) + model_socket = runner_temp / f"workstream-api-contract-model-{runtime_token}.sock" + if os.path.lexists(model_socket): + raise RuntimeError("scripted model socket path is not fresh") + if env.get("WORKSTREAM_ARTIFACT_STORE_BACKEND", "disabled") == "disabled": + local_root = runner_temp / f"workstream-api-contract-artifacts-{runtime_token}" + scratch_root = runner_temp / f"workstream-api-contract-scratch-{runtime_token}" + local_root.mkdir(mode=0o700) + scratch_root.mkdir(mode=0o700) + env.update( + { + "WORKSTREAM_ARTIFACT_STORE_BACKEND": "local", + "WORKSTREAM_ARTIFACT_LOCAL_ROOT": str(local_root), + "WORKSTREAM_ARTIFACT_SCRATCH_ROOT": str(scratch_root), + "WORKSTREAM_ARTIFACT_ADMISSION_TASK_MAXIMUM_BYTES": "67108864", + "WORKSTREAM_ARTIFACT_ADMISSION_PRODUCER_MAXIMUM_BYTES": "67108864", + "WORKSTREAM_ARTIFACT_ADMISSION_PROJECT_MAXIMUM_BYTES": "67108864", + "WORKSTREAM_ARTIFACT_ADMISSION_DEPLOYMENT_MAXIMUM_BYTES": "67108864", + } + ) + # The deterministic runtime does not call the capability. A concrete absent + # path proves typed profile/session admission without a provider bypass. + env["WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET"] = str(model_socket) + + async def exercise_rate_control_contract(env: dict[str, str]) -> None: """Prove one durable denial without exposing the pseudonymous key.""" subject = f"api-contract-rate-{uuid4()}" @@ -2300,6 +2305,7 @@ async def main(env: dict[str, str]) -> None: if __name__ == "__main__": api_env = api_environment() + configure_scripted_model_session_environment(api_env) assert_isolated_database_url(api_env["WORKSTREAM_DATABASE_URL"]) os.environ.update(api_env) get_settings.cache_clear() diff --git a/backend/tests/test_api_contract_e2e.py b/backend/tests/test_api_contract_e2e.py index 710a65186..669f41b20 100644 --- a/backend/tests/test_api_contract_e2e.py +++ b/backend/tests/test_api_contract_e2e.py @@ -191,7 +191,7 @@ def test_api_contract_uses_runner_owned_minio_namespace( assert settings.artifact_s3_bucket == "workstream-ci-isolated-012345abcdef" -def test_api_contract_enables_local_artifacts_for_typed_model_sessions( +def test_full_api_contract_enables_local_artifacts_for_typed_model_sessions( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Provider-free API consumers still admit the typed setup-model capability.""" @@ -206,9 +206,14 @@ def test_api_contract_enables_local_artifacts_for_typed_model_sessions( monkeypatch.delenv(key, raising=False) env = api_contract.api_environment() + assert env.get("WORKSTREAM_ARTIFACT_STORE_BACKEND", "disabled") == "disabled" + assert "WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET" not in env + api_contract.configure_scripted_model_session_environment(env) assert env["WORKSTREAM_ARTIFACT_STORE_BACKEND"] == "local" assert env["WORKSTREAM_ARTIFACT_LOCAL_ROOT"] != env["WORKSTREAM_ARTIFACT_SCRATCH_ROOT"] + assert Path(env["WORKSTREAM_ARTIFACT_LOCAL_ROOT"]).stat().st_mode & 0o777 == 0o700 + assert Path(env["WORKSTREAM_ARTIFACT_SCRATCH_ROOT"]).stat().st_mode & 0o777 == 0o700 assert env["WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET"].startswith(str(tmp_path) + "/") for key, value in env.items(): if key.startswith("WORKSTREAM_ARTIFACT_") or key == ( From c0158910684e1ff6e76409762795cab2b36d4ab8 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 24 Oct 2026 17:04:56 +0100 Subject: [PATCH 115/115] Classify scripted model drill identifiers --- backend/scripts/identifier_generation_classifications.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/scripts/identifier_generation_classifications.json b/backend/scripts/identifier_generation_classifications.json index 101b343e3..8db95b1aa 100644 --- a/backend/scripts/identifier_generation_classifications.json +++ b/backend/scripts/identifier_generation_classifications.json @@ -83,6 +83,7 @@ "scripts/admin_api_drill.py|AuthorityDrill.last_admin.revoke|Assign:5eb0e430a7bfa378|0": "uuid4:call:transport_token", "scripts/admin_api_drill.py|AuthorityDrill.last_admin.revoke|Assign:50c09de1b190b007|0": "uuid4:call:transport_token", "scripts/api_contract_e2e.py|flow_settings|Return:dc575af68f5f5f0c|0": "uuid4:call:ephemeral_secret", + "scripts/api_contract_e2e.py|configure_scripted_model_session_environment|Assign:8450cf2c8dff45cc|0": "uuid4:call:drill_namespace", "scripts/local_flow_tokens.py|issue_flow_token|Assign:dd98645354be1f57|0": "uuid4:call:external_identity_fixture", "scripts/api_contract_e2e.py|exercise_rate_control_contract|Assign:fed221fef92f603b|0": "uuid4:call:external_identity_fixture", "scripts/api_contract_e2e.py|request_json|Assign:9a076497f47031d6|0": "uuid4:call:transport_token", @@ -202,6 +203,8 @@ "scripts/api_contract_e2e.py|activate_guide_for_e2e|Expr:8ef328046b881a1c|0": "uuid4:call:transport_token", "scripts/api_contract_e2e.py|activate_guide_for_e2e|Assign:71aef60013930cda|0": "uuid4:call:transport_token", "scripts/api_contract_e2e.py|exercise_api_contract|Expr:2866edfb74707b74|0": "uuid4:call:transport_token", + "scripts/api_contract_e2e.py|exercise_api_contract|Expr:6ac1bf5c75b1d132|0": "uuid4:call:transport_token", + "scripts/guide_compilation_e2e.py|publish_scripted_model_profile|Assign:d4603dbadbb4e32f|0": "uuid4:call:transport_token", "app/modules/tasks/task_batch_transitions.py|TaskBatchTransitionCommands._prepare|Assign:b01b4c43bfe447dd|0": "uuid4:call:transport_token", "app/modules/tasks/task_import_batches.py|TaskImportBatchCommands._prepare_rows|Expr:49b976e107dcb310|0": "uuid4:call:transport_token", "scripts/provision_local_service_actors.py|idempotency_key|Return:7cb5dd9bb0dd8d6d|0": "uuid5:call:deterministic_selector",