From 7dc725db9507b647af06b5eb9e542bf70658f92a Mon Sep 17 00:00:00 2001 From: grishencorp Date: Wed, 30 Sep 2026 18:50:36 -0300 Subject: [PATCH 1/4] Add opt-in standalone governed action controls --- docs/action-control/README.md | 91 +++++ docs/action-control/integration.md | 101 ++++++ docs/action-control/qualification.json | 78 +++++ docs/action-control/qualification.md | 68 ++++ docs/action-control/release-plan.md | 47 +++ package.json | 4 +- .../collective-control-postgres/README.md | 11 + .../migrations/001_action_admission.down.sql | 1 + .../migrations/001_action_admission.up.sql | 6 + .../migrations/001_action_approvals.down.sql | 1 + .../migrations/001_action_approvals.up.sql | 9 + .../collective-control-postgres/package.json | 8 + .../src/action-admission.ts | 68 ++++ .../src/action-approvals.ts | 64 ++++ packages/inference-control/README.md | 32 ++ packages/inference-control/package.json | 12 + .../inference-control/src/action-admission.ts | 266 +++++++++++++++ .../inference-control/src/action-approvals.ts | 313 ++++++++++++++++++ .../inference-control/src/action-effects.ts | 77 +++++ packages/inference-control/src/tools.ts | 82 +++++ scripts/pack-consumers/action-control.mjs | 66 ++++ .../inference-control-alpha3.mjs | 12 + scripts/verify-action-control.mjs | 26 ++ scripts/verify-public-consumer.mjs | 15 + tests/helpers/action-admission-scenarios.mjs | 50 +++ tests/helpers/action-approval-fixtures.mjs | 40 +++ tests/helpers/action-control-crash-worker.mjs | 23 ++ tests/helpers/action-control-fixtures.mjs | 56 ++++ ...control-action-admission-postgres.test.mjs | 29 ++ ...nference-control-action-admission.test.mjs | 104 ++++++ ...nference-control-action-admission.test.mts | 14 + ...control-action-approvals-postgres.test.mjs | 40 +++ ...nference-control-action-approvals.test.mjs | 87 +++++ ...nference-control-action-approvals.test.mts | 16 + ...e-control-action-control-postgres.test.mjs | 191 +++++++++++ .../inference-control-action-effects.test.mjs | 35 ++ .../inference-control-action-effects.test.mts | 8 + tests/inference-control-gateways.test.mts | 16 + .../inference-control-grant-builder.test.mjs | 253 ++++++++++++++ 39 files changed, 2419 insertions(+), 1 deletion(-) create mode 100644 docs/action-control/README.md create mode 100644 docs/action-control/integration.md create mode 100644 docs/action-control/qualification.json create mode 100644 docs/action-control/qualification.md create mode 100644 docs/action-control/release-plan.md create mode 100644 packages/collective-control-postgres/migrations/001_action_admission.down.sql create mode 100644 packages/collective-control-postgres/migrations/001_action_admission.up.sql create mode 100644 packages/collective-control-postgres/migrations/001_action_approvals.down.sql create mode 100644 packages/collective-control-postgres/migrations/001_action_approvals.up.sql create mode 100644 packages/collective-control-postgres/src/action-admission.ts create mode 100644 packages/collective-control-postgres/src/action-approvals.ts create mode 100644 packages/inference-control/src/action-admission.ts create mode 100644 packages/inference-control/src/action-approvals.ts create mode 100644 packages/inference-control/src/action-effects.ts create mode 100644 scripts/pack-consumers/action-control.mjs create mode 100644 scripts/verify-action-control.mjs create mode 100644 tests/helpers/action-admission-scenarios.mjs create mode 100644 tests/helpers/action-approval-fixtures.mjs create mode 100644 tests/helpers/action-control-crash-worker.mjs create mode 100644 tests/helpers/action-control-fixtures.mjs create mode 100644 tests/inference-control-action-admission-postgres.test.mjs create mode 100644 tests/inference-control-action-admission.test.mjs create mode 100644 tests/inference-control-action-admission.test.mts create mode 100644 tests/inference-control-action-approvals-postgres.test.mjs create mode 100644 tests/inference-control-action-approvals.test.mjs create mode 100644 tests/inference-control-action-approvals.test.mts create mode 100644 tests/inference-control-action-control-postgres.test.mjs create mode 100644 tests/inference-control-action-effects.test.mjs create mode 100644 tests/inference-control-action-effects.test.mts create mode 100644 tests/inference-control-grant-builder.test.mjs diff --git a/docs/action-control/README.md b/docs/action-control/README.md new file mode 100644 index 00000000..152bb94c --- /dev/null +++ b/docs/action-control/README.md @@ -0,0 +1,91 @@ +# Standalone governed external actions + +**Status:** additive, opt-in source profile; qualification passed in an isolated +checkout on 2026-09-30. Not published to npm. Operational obligations and release +steps remain explicit; no production-scale or universal external guarantee is claimed. + +This profile supports hosts such as The Agent Control without requiring Agent Rooms. +Existing ActionGateway, grants, authority/assessment resolvers and dispatchers retain +execution ownership. Existing entry points/default behavior, historical rows and +migration bytes remain unchanged. + +## Public surfaces + +| Entry point | Responsibility | +| --- | --- | +| `@agentplat/inference-control/tools` | Existing gateway/repositories; additive typed grant preparation and conservative recovery of interrupted reservations | +| `@agentplat/inference-control/action-approvals` | Exact reviewed targets, authenticated independent decisions, persistent monotonic lifecycle and a narrowing assessment resolver | +| `@agentplat/inference-control/action-admission` | Atomic revocation fences, shared resource accounts, effect receipts and verified reconciliation | +| `@agentplat/inference-control/action-effects` | Explicit atomic conditional-write and idempotent receipt contract | +| `@agentplat/collective-control-postgres/action-approvals` | Tenant-scoped durable approval store and separately invoked migration | +| `@agentplat/collective-control-postgres/action-admission` | Transactional admission store and separately invoked migration | + +Read [integration.md](integration.md) for composition, trusted ports, migration and +recovery instructions. [release-plan.md](release-plan.md) defines coordinated delivery +and publication. [qualification.md](qualification.md) maps requirements to evidence. + +## Guarantees and boundaries + +Approval targets bind input, full action binding, trusted preconditions, policy and +revocation versions. Decisions require an authenticated authorized independent person. +Approval does not grant authority or override base denial. Changed facts invalidate; +unavailable facts deny. Expiry/invalidation cannot be undone by a restart or old clock. +One approval cannot authorize multiple logical effects. + +Admission atomically verifies active agent/connector/organization epochs and required +approval evidence, then reserves all configured charges or none. Account IDs include +scope and immutable period, with safe-integer units and revisioned limits. Policy +changes/reactivation do not reset consumption. A trusted host selects every applicable +account and quotes an upper bound that the adapter must enforce. + +Admission is the linearization point: suspension/invalidation committed first blocks; +admission committed first may finish. The external destination must apply approved +resource preconditions atomically with its write to close the read/write race. +Capabilities are host-attested integration contracts, requiring adapter conformance. +Unsupported destinations must display a weaker profile; no universal interception, +rollback, cancellation or exactly-once promise exists. + +Effects begin indeterminate before dispatch. Unknown outcomes retain reservations and +never automatically retry/refund. Terminal not_applied proof refunds once; succeeded +retains cost. A temporary lookup miss is insufficient. Interrupted reserved grants +require proof the original worker stopped or was fenced before conservative recovery. +Recovery never reissues authority. Grant and effect reconciliation are separate +idempotent durable operations, still allowed while suspended. + +## Reproduce qualification + +Use a disposable PostgreSQL database with schema-creation privileges. Tests only +create/drop isolated UUID-named schemas; no production migration or deployment runs. + +```sh +pnpm install --frozen-lockfile +AGENTPLAT_POSTGRES_TEST=1 PGHOST=127.0.0.1 PGDATABASE=postgres \ + pnpm run verify:action-control +``` + +The focused gate refuses missing database enablement, builds the workspace, checks +public types, requires zero failed/skipped/TODO/cancelled scenarios and runs independent +prepared-tarball consumption. Set connection/authentication for your local database; +connection strings are not written into qualification metadata. + +Broader validation separately covers full type checks, unit/adapter suites, public +surface, platform/specification and stable compatibility. Skips/TODOs are reported, +not passed evidence. CI release gates still apply before publication. + +## Host obligations + +- Verified identity, role checks, policy evaluation, server clock and exact payload + storage/display; no client tenant IDs, costs or claimed approvers as authority. +- Durable grant-to-approval mapping, trusted facts/quotes and all required accounts; + required approval references cannot be omitted from admission. +- Adapter-owned idempotency/fencing, conditional writes and terminal receipts; + actual consumption cannot exceed quotes. +- Credentials, redaction/retention, backups, scheduling, diagnostics and recovery. +- Deployment capacity verification. The reference admission adapter serializes by + tenant and retains full receipt history; throughput/compaction/partitioning require + separate operational qualification preserving accounting and idempotency evidence. + +Record digests detect accidental corruption, not a malicious infrastructure admin. +MCP routing, UI, business policies, connectors, licensing and commercial portal remain +exclusively owned by The Agent Control. Its npm dependency update follows actual +coordinated publication, not the presence of these source files. diff --git a/docs/action-control/integration.md b/docs/action-control/integration.md new file mode 100644 index 00000000..0f89feac --- /dev/null +++ b/docs/action-control/integration.md @@ -0,0 +1,101 @@ +# Integrating standalone governed actions + +The optional source additions are not yet on npm. Install a coordinated published +version when available. No Agent Rooms, Agent Mesh or remote ACL service is required. + +## Composition and owners + +```text +Authenticated host -> grant repository -> ActionGateway + approval assessment -> | + admission dispatcher + | + conditional dispatcher + | + external executor +``` + +The host owns verified identity, policy, approver roles, exact request storage/display, +clocks, trusted resource facts and selection of ALL applicable budget accounts. +AgentPlat owns the linkage to a logical effect, currentness, reservations and receipts. + +Use the existing PostgresActionGrantRepositoryV1 and existing collective migration +runner for durable grants. Explicitly invoke runActionApprovalMigrationsV1 and +runActionAdmissionMigrationsV1 for the optional stores. Imports/upgrades never invoke +migrations. Old rows and migration bytes remain unchanged. Use one intended tenant +and schema across these stores; missing required storage must deny, not use memory. + +## Request and approval + +Authenticate the requesting agent in the host, normalize and retain its exact payload, +and read trusted policy/resource facts. createActionApprovalTargetV1 binds input, +complete action binding, resource preconditions and authority/policy versions. + +For required review, ActionApprovalServiceV1.request/decide consume authenticated +context. Show the exact retained request in the UI. A purpose, explanation, signal +or claimed actor cannot grant permission. Reevaluation of current policy remains +required: human approval cannot override a hard denial. + +Use createActionGrantV1 with the reviewed target digest as assessmentTargetDigest, +then issueActionGrantV1 through the existing repository. Persist the exact grant-to- +approval mapping. The approval assessment resolver narrows the base resolver and +reloads trusted current facts. Changed targets invalidate; unavailable facts deny. + +All timestamps come from the host clock. Expired/invalidated decisions never revive; +a changed target requires new reviewed identity. Do not forward agent-supplied times. + +## Admission and effect + +Configure three active revocation fences (agent, connector, organization) through +trusted administrative operations. Epoch changes invalidate old work. Configure +budgets with explicit safe-integer units, immutable period boundaries, revisions +and stable account IDs that include scope/period. Renewal creates a new account ID; +changes do not reset consumption. Calendar/timezone/rolling policy belongs to the host. + +The trusted quote includes all applicable accounts, upper-bound charges, actual +fences and a stable logical effect ID. Adapters must not exceed quoted consumption; +reject operations whose maximum cannot be bounded. Agent-supplied cost is not a fact. + +createActionAdmissionDispatcherV1 wraps the existing dispatcher after gateway checks. +Include the persisted approval reference when policy requires one; null is only for +explicitly authorized unreviewed actions. Admission locks approval evidence alongside +fences/budgets. Missing schema or port cannot disable a required check. + +For destinations supporting atomic conditional writes AND idempotent terminal receipts, +use createConditionalActionDispatcherV1 as the downstream dispatcher. Resolve the +immutable reviewed constraints, not a permissive fresh baseline. Its execution port +must check conditions and perform the effect atomically at the destination. Receipts +correlate idempotency key, action/input/precondition digests and terminal outcome. +Pin executor identity/version/capability profile in the binding handler digest. +Declarations are contracts; conformance tests must verify the actual adapter. + +A destination without conditional writes cannot support the strict precondition +profile. Expose weaker integrations with their race boundary explicitly identified. +Read-before-write alone is insufficient. There is no universal cancellation, +rollback or exactly-once promise for arbitrary external services. + +## Recovery + +Admission committed first may finish after suspension; suspension committed first +denies. Unknown effects retain all budget holds and never automatically retry/refund. +ActionAdmissionServiceV1.reconcile requires an authoritative receipt bound to the +request digest. not_applied must prove the original attempt cannot later apply; +lookup misses are insufficient. Concurrent refunds happen once. + +If a process terminated with a grant still reserved, recoverReservedActionGrantV1 +requires the host to prove its original worker stopped or was fenced. It transitions +to indeterminate, never issued. Reconcile the effect receipt and existing grant via +reconcileActionGrantV1 with original reservation/attempt identity. A crash between +those two durable boundaries is handled by repeating reconciliation, not dispatch. +Facts can be reconciled while suspended without restoring execution authority. + +## Operations and evidence + +The initial admission store serializes by tenant and retains all receipts: a bounded +correctness reference, not production-scale throughput evidence. Compaction/partitioning +must preserve accounting and idempotency receipts. Record digests detect corruption, +not a malicious administrator. Credentials, redaction, scheduling, backup, connectors, +MCP integration and user-facing review workflows remain host responsibilities. + +See README.md for verification and release-plan.md for coordinated delivery. Source +qualification and prepared tarballs do not constitute an npm release. diff --git a/docs/action-control/qualification.json b/docs/action-control/qualification.json new file mode 100644 index 00000000..25eb3154 --- /dev/null +++ b/docs/action-control/qualification.json @@ -0,0 +1,78 @@ +{ + "schemaVersion": 1, + "baseCommit": "0142d6e607eec75c0464c13949df8c2d95b48f1e", + "node": "24.20.0", + "pnpm": "11.25.0", + "status": "source-qualified-unpublished", + "focused": { + "passed": 46, + "failed": 0, + "skipped": 0, + "todo": 0 + }, + "unit": { + "passed": 1480, + "failed": 0, + "skipped": 1, + "todo": 6 + }, + "adapters": { + "passed": 380, + "failed": 0, + "skipped": 0, + "todo": 0, + "groups": 31 + }, + "checks": { + "build": "passed", + "types": "passed", + "publicAudit": "passed", + "stableCompatibility": "passed", + "platform": "passed", + "specification": "passed", + "independentTarballs": "passed" + }, + "sourceSha256": { + "package.json": "a0cd195fc98ae7645046ea5e406bd4c31475a0383ee91a36891d911212361256", + "packages/collective-control-postgres/migrations/001_action_admission.down.sql": "1682c8f878368333fb62b6f1e7bb36a6ae24fc26790a83f5c80dd8e381a97c97", + "packages/collective-control-postgres/migrations/001_action_admission.up.sql": "3a429d1c8569768ba4b7036441ccc07fe560bbf09da817dfab39d0ccf3018da4", + "packages/collective-control-postgres/migrations/001_action_approvals.down.sql": "fe0735882478e9a06868657d11a90860c1f2c8169be73b5bfa0b6515348804fa", + "packages/collective-control-postgres/migrations/001_action_approvals.up.sql": "f58ded8765a66d254784ef8a76172a42970cb83ffed4d37b3994dacd494a8d82", + "packages/collective-control-postgres/package.json": "dee781cf28228cc7df43afb88a4955c0e05bbeb5fec85fba9ea6043039ddabc2", + "packages/collective-control-postgres/src/action-admission.ts": "719cfd08a0427bda5c215754022af64499486d997c33475b14535057ea8542f4", + "packages/collective-control-postgres/src/action-approvals.ts": "577b22cf7b11f51bdc27accf79c267df0539a8dfdfb0316e15907c9ef2176f32", + "packages/inference-control/package.json": "a81ee47207621e06bffd20f83068d6f191caf932361aed65093b0a5ddd852bfb", + "packages/inference-control/src/action-admission.ts": "4f76518c9f5bfd8e1d65aae19906ae1831ac0ec9feba33dbe2f9c62f32f9bde5", + "packages/inference-control/src/action-approvals.ts": "128e16a1fa581a9a7893d3bbc842ce2d05739646ba9fff6ee837b13289158f20", + "packages/inference-control/src/action-effects.ts": "9a1b572011d536dbb838d3e79bc7de093a1a903f1d3bcb83e49fbbd2934ddbe5", + "packages/inference-control/src/tools.ts": "c9ad2a574512a95b3bcae2f213e5328565e450c3a616b9a38bb516249580c02a", + "scripts/pack-consumers/action-control.mjs": "53c5463d6f2811a8e6b5fbb20e043899e0ba22ca648adefc58068935a4c5b1f7", + "scripts/pack-consumers/inference-control-alpha3.mjs": "484e285cbe508bb4e3352f010ad4c15d24ce2b0a6a79d271adc1f3d2a5d4a881", + "scripts/verify-action-control.mjs": "eff1816656c81a6ec2ee8e1965df67767fb8983db7f2bd32272d3a6c284c1214", + "scripts/verify-public-consumer.mjs": "4610aa136ff4ccbcd9a52cd7829f12da7a258e1f65be1682875713261b6e411f", + "tests/helpers/action-admission-scenarios.mjs": "93992af4b234cb87a2dcd5f13cd68fddc7506b3131af44149232cc6f9eca7b17", + "tests/helpers/action-approval-fixtures.mjs": "9348c92d6497189ac3341682d3b34a90aad9c3ab583c929f470a23871a7965d1", + "tests/helpers/action-control-crash-worker.mjs": "336228a98adf012cfec5fd3b5d24613392418b1e328a18d61185b4251709454a", + "tests/helpers/action-control-fixtures.mjs": "a88345c34ae9f6ad64ce27249afa94ac69c84ae111dd7df2ef511b2c9b42337c", + "tests/inference-control-action-admission-postgres.test.mjs": "72b963c79dda099c4ddda09fb12fd3a7bdd96d450ad8909607d349940d0336d0", + "tests/inference-control-action-admission.test.mjs": "ebe893a515ecd3859daa1dc9d9540db641e07251a53f6ad3fd4e75d4b7caca7b", + "tests/inference-control-action-admission.test.mts": "26ccc98da41b928247f9fa58e0a299011b8ad5a93cf6ad6fef0aadad018f6fa5", + "tests/inference-control-action-approvals-postgres.test.mjs": "adade88608e8218b456754f1863aed857a2d4441731ec50d82222940b7b00cbd", + "tests/inference-control-action-approvals.test.mjs": "ceb9092ea0f98820edac850251faf4e0013b2218c682dcb5035a200a3ac4e582", + "tests/inference-control-action-approvals.test.mts": "88785d9ee90d328dbb6d14eb1388449ec6491e6147a733b2eac1627619e425c0", + "tests/inference-control-action-control-postgres.test.mjs": "4792c47fd75807a01172d24789dd76f797f2887dd02d2ae7eb79d6d926761da5", + "tests/inference-control-action-effects.test.mjs": "f3aaf85ab259d2c9866e24b7c60c0391a191183bd09aa83c9e485a8fe8c7d005", + "tests/inference-control-action-effects.test.mts": "6a8f6aed7aa4fa08ef4130e62dd8852ccacc4d01b9fc4470851d299ce6120cbb", + "tests/inference-control-gateways.test.mts": "064eaf9a07a78d221ec34f8f3019a8d0b0d669d075dd316b1c245f3dc395c60a", + "tests/inference-control-grant-builder.test.mjs": "931efc054cca583333da55508ee98b09694a6326e669c8d4654d1c162987f8c0" + }, + "logSha256": { + "build": "71f13d691eb01b99d5a8591bab1341c0698e60d11d8a47e622dfb0e2bb1afe75", + "types": "d41ac6d17429427750f5dfde0112b1628aaff5bb88166a2415d8c4656fcbfd8f", + "qualification": "b4bc7c4649060b16dce076ec768483c6f9a119a436e5005d3f83c3b4f3e95fd2", + "consumer": "7520e904a8d64108c36f12b1a5d11309eec644a5afdeec531d8f7c198930448c", + "unit-isolated": "935a3205da1f48b17abe7b480c54b175f99f821ead4385f262a4354b225694f7", + "adapters": "5bb89f95388eb0e1eea0518b8a6845378e559081075d020c905381bad8a76503", + "public-audit": "9d299c1d2ba8f06f01812c550dc22fc2c011de999b880512f75bac3e6decf383" + } +} diff --git a/docs/action-control/qualification.md b/docs/action-control/qualification.md new file mode 100644 index 00000000..81d1c58d --- /dev/null +++ b/docs/action-control/qualification.md @@ -0,0 +1,68 @@ +# Source qualification and completion audit — 2026-09-30 + +Environment: isolated `codex/standalone-action-control` checkout, Node 24.20.0, +pnpm 11.25.0, disposable schemas in local PostgreSQL. No cloud resources or npm +publications were changed. Existing research/output work was excluded and preserved. + +| Goal requirement | Authoritative source/scenario | Observed result | +| --- | --- | --- | +| No mandatory Agent Rooms | Standalone scope in composed gateway and independent consumer | Passed; the profile composes existing grant/gateway owners | +| Exact approval and preconditions | `action-approvals.ts`, approval tests and conditional-write PostgreSQL scenario | Input, binding, facts, policy and authority versions remain linked; altered targets invalidate | +| Independent authenticated review | Approval service host access port and unauthorized/self-approval scenarios | Missing identity, foreign tenant, agents and requester self-approval deny | +| Revalidation and revocation | Approval guard under admission transaction; epoch/account checks | Invalidation during quotation produces no charge/effect; stale agent/connector/organization epochs deny | +| Shared limits and explicit periods | Shared admission scenarios in memory and PostgreSQL | Competing reservations cannot exceed caps; all charges reserve together; units/revisions/periods checked | +| Uncertain results and refunds | Durable receipts and concurrent reconciliation scenarios | Unknown holds remain; terminal verified not_applied refunds once; contradictory proof cannot rewrite outcome | +| Restart and actual worker loss | Pool reopen plus hard process termination scenario | Reserved grant stays reserved until verified recovery; reconciliation succeeds without redispatch | +| Atomic destination preconditions | `action-effects.ts` and real conditional SQL write fixture | Resource changed after review produces terminal not_applied, no write, verified refund | +| Backward compatibility | Stable verifier, legacy grant bytes, old-row migration scenario and regression suites | Existing exports/types/default behavior preserved; old issued grant byte-identical after optional migration | +| Independent artifact consumption | `verify:action-control-consumer` | Runtime entry points, public types and optional SQL files verified from installed tarballs outside workspace | +| Reviewable delivery | Isolated branch and source qualification/host guide | Only scoped feature files; source APIs remain unpublished | +| Coordinated publication plan | `release-plan.md` | Fresh version and exact-artifact release process specified; publication is a later authorized operation | +| Product separation | Feature file set and integration guide | No MCP gateway, UI, business connectors, licenses or portal implemented in AgentPlat | + +## Executed checks + +| Check | Result | +| --- | --- | +| Full workspace build | Passed | +| Full workspace type checks + public TypeScript fixtures | Passed | +| Focused `verify:action-control` | 46 passed, zero failures/skips/TODO/cancelled; includes PostgreSQL and independent tarballs | +| Full root unit suite | 1,480 passed, 0 failed, 1 skipped, 6 TODO (1,487 total) | +| Adapter suite with PostgreSQL enabled | 380 passed, zero failures/skips/TODO across 31 summary groups | +| Normal public-surface audit | Passed; no added exclusions/exceptions | +| Platform boundaries and specification | Passed | +| Stable compatibility | Passed: 65 baseline packages, 216 baseline entry points, 83 unchanged type contracts, 222 current entry points | +| Diff whitespace | Passed | + +The broad unit suite's skipped container test and six existing Mesh work/lease TODOs +are not counted as successful evidence. Adapter success does not establish a live +Temporal/Redis deployment; individual suite labels and enablement define coverage. +Neither source qualification nor tests constitute a production-scale certificate. +The complete release `pnpm run check` and remote CI remain required by the publication +process and have not been claimed here. + +Working-session logs are `/tmp/agentplat-action-control-{build,types,qualification, +consumer,unit-isolated,adapters,public-audit}.log`. Source hashes and summaries are +recorded in `qualification.json`. Run the qualification gate to reproduce the +focused result; run the broader repository commands for their full scope. + +## Explicit operational boundaries + +Host identity/policy/clock/fact/quote ports are trusted integration boundaries. +Conditional-execution and idempotency declarations must be substantiated by each +real destination adapter. Weaker destinations cannot acquire atomic guarantees +from library flags. Upper-bound accounting and no automatic unknown-effect retry +remain mandatory in the composed profile. + +Admission committed before suspension or invalidation may finish. Receipt-based +reconciliation is allowed while suspended. Recovery requires the original worker +stopped or fenced, not merely an observation timeout. No new action grant is issued +by approval, budget reservation, recovery or reconciliation. + +The initial admission adapter is tenant-serialized with growing retained history. +Storage scaling and compaction are deployment work requiring preserved receipts and +accounting. This limitation is explicit rather than a claim of validated scale. + +The requested source-support scope is verified. Registry publication, ACL adoption, +remote CI/release gates and production deployment are later steps, not implied by +this completion audit. diff --git a/docs/action-control/release-plan.md b/docs/action-control/release-plan.md new file mode 100644 index 00000000..1f89daf0 --- /dev/null +++ b/docs/action-control/release-plan.md @@ -0,0 +1,47 @@ +# Coordinated delivery and publication plan + +## Release boundary + +The additions are opt-in source APIs, not npm 1.1.0 functionality. Never attempt +to overwrite published 1.1.0 bytes. A fresh coordinated minor release is the +proposed vehicle because these are additive public capabilities. Resolve the +actual version through the existing release process and registry availability +checks when publication is authorized. + +Changes stay in existing packages: inference-control (tools builder, approval +and admission subpaths) and collective-control-postgres (approval and admission +adapters). No new package cohort entry or default runtime activation is needed. +The Agent Control must keep using published APIs until it can install the new +coordinated version. Prepared tarballs qualify consumption but are not a registry +release and must not be described as one. + +## Reviewable delivery requirements + +1. Complete contracts for external preconditions, capability limitations and + reconciliation; verify their composed persistent scenarios. +2. Inspect all newly added public inputs and persistence transitions, especially + caller-supplied scope, clock, payload substitution and revocation ordering. +3. Run full builds, type checks, unit/adapter suites and existing compatibility, + platform, specification and public audits. Report skips/TODOs separately. +4. Run `pnpm run verify:action-control-consumer`: install prepared tarballs in an + external directory, execute public APIs, compile public types, verify migrations + and retain existing Collective Runtime/Audit consumers. +5. Isolate only this feature and its docs/tests from unrelated research/output + work. Preserve those files; do not include them in a release or weaken gates. +6. Prepare a focused branch/PR or equivalent reviewable diff with exact validation + evidence and remaining host obligations. Check migrations from the old version + to the opt-in profile; existing installations do not invoke the new migrations. + +## Publication after authorization + +Use the existing `scripts/set-version.mjs`, approved-source verification, +artifact preparation and release-level deployment approval processes described +in `docs/security/npm-direct-release.md` and `docs/security/npm-release-security.md`. +Preserve protected main, exact staged-byte checks, OIDC and npm authentication +boundaries. This goal does not authorize registry publication or cloud deployment. + +After actual publication, verify registry bytes and public consumption, then +update ACL to the published coordinated dependency version and run its gateway +integration suite. Version pinning, migration invocation, durable grant/approval +mapping, trusted quote/fact ports and idempotency guarantees are explicit adoption +steps. No old application is automatically enrolled. diff --git a/package.json b/package.json index 876e8765..b7e3d919 100644 --- a/package.json +++ b/package.json @@ -216,7 +216,9 @@ "verify:purpose-governance": "node scripts/verify-purpose-governance.mjs", "verify:purpose-consumer": "node scripts/verify-public-consumer.mjs --purpose-governance", "verify:stable1-compatibility": "node scripts/verify-stable1-compatibility.mjs", - "verify:jev-consumer": "node scripts/verify-public-consumer.mjs --optional-jev" + "verify:jev-consumer": "node scripts/verify-public-consumer.mjs --optional-jev", + "verify:action-control-consumer": "node scripts/verify-public-consumer.mjs --action-control", + "verify:action-control": "node scripts/verify-action-control.mjs" }, "devDependencies": { "@agentplat/audit": "workspace:*", diff --git a/packages/collective-control-postgres/README.md b/packages/collective-control-postgres/README.md index e8d31686..75955faa 100644 --- a/packages/collective-control-postgres/README.md +++ b/packages/collective-control-postgres/README.md @@ -24,3 +24,14 @@ worker and creates no timer. Caller-owned pools are never closed. Migration rollback is destructive and requires the exact confirmation token. Before rollback, `getCollectiveRollbackReadinessV1` must report no active work, reserved/dispatching permits, active grants or indeterminate effects. + +## Optional standalone action approvals (unpublished source) + +`./action-approvals` provides a tenant-scoped approval repository and a separately +invoked migration. Existing migrations and grant repositories retain their behavior. +See [standalone action control](../../docs/action-control/README.md) for guarantees, +verification and remaining composition requirements. + +`./action-admission` adds an opt-in tenant-serialized PostgreSQL store and separate +migration for revocation fences, shared budget accounts and effect receipts. This +reference adapter does not claim production-scale throughput or bounded history. diff --git a/packages/collective-control-postgres/migrations/001_action_admission.down.sql b/packages/collective-control-postgres/migrations/001_action_admission.down.sql new file mode 100644 index 00000000..29c4100d --- /dev/null +++ b/packages/collective-control-postgres/migrations/001_action_admission.down.sql @@ -0,0 +1 @@ +DROP TABLE __AGENTPLAT_SCHEMA__.action_admission_states_v1; diff --git a/packages/collective-control-postgres/migrations/001_action_admission.up.sql b/packages/collective-control-postgres/migrations/001_action_admission.up.sql new file mode 100644 index 00000000..eea3fa0a --- /dev/null +++ b/packages/collective-control-postgres/migrations/001_action_admission.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE __AGENTPLAT_SCHEMA__.action_admission_states_v1 ( + tenant_id text PRIMARY KEY, + record jsonb NOT NULL, + record_digest text NOT NULL, + CHECK (record->>'tenantId' = tenant_id) +); diff --git a/packages/collective-control-postgres/migrations/001_action_approvals.down.sql b/packages/collective-control-postgres/migrations/001_action_approvals.down.sql new file mode 100644 index 00000000..5b919057 --- /dev/null +++ b/packages/collective-control-postgres/migrations/001_action_approvals.down.sql @@ -0,0 +1 @@ +DROP TABLE __AGENTPLAT_SCHEMA__.action_approvals_v1; diff --git a/packages/collective-control-postgres/migrations/001_action_approvals.up.sql b/packages/collective-control-postgres/migrations/001_action_approvals.up.sql new file mode 100644 index 00000000..a109f297 --- /dev/null +++ b/packages/collective-control-postgres/migrations/001_action_approvals.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE __AGENTPLAT_SCHEMA__.action_approvals_v1 ( + tenant_id text NOT NULL, + approval_id text NOT NULL, + record jsonb NOT NULL, + record_digest text NOT NULL, + PRIMARY KEY (tenant_id, approval_id), + CHECK (record->>'tenantId' = tenant_id), + CHECK (record->>'approvalId' = approval_id) +); diff --git a/packages/collective-control-postgres/package.json b/packages/collective-control-postgres/package.json index 45f5fc66..09a6086a 100644 --- a/packages/collective-control-postgres/package.json +++ b/packages/collective-control-postgres/package.json @@ -11,6 +11,14 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./action-approvals": { + "types": "./dist/action-approvals.d.ts", + "import": "./dist/action-approvals.js" + }, + "./action-admission": { + "types": "./dist/action-admission.d.ts", + "import": "./dist/action-admission.js" } }, "files": [ diff --git a/packages/collective-control-postgres/src/action-admission.ts b/packages/collective-control-postgres/src/action-admission.ts new file mode 100644 index 00000000..3fa4194c --- /dev/null +++ b/packages/collective-control-postgres/src/action-admission.ts @@ -0,0 +1,68 @@ +import { readFile } from 'node:fs/promises'; +import type { Pool } from 'pg'; +import { defaultPostgresSchema, normalizePostgresIdentifier, quotePostgresIdentifier, + runPostgresMigrations } from '@agentplat/postgres'; +import { assertActionApprovalAdmissionV1, validateActionAdmissionStateV1, type ActionApprovalAdmissionGuardV1, type ActionAdmissionStateV1, + type ActionAdmissionStoreV1 } from '@agentplat/inference-control/action-admission'; +import type { ActionApprovalRecordV1 } from '@agentplat/inference-control/action-approvals'; +import { controlDigest, type ControlJson } from '@agentplat/inference-control/tools'; + +export async function runActionAdmissionMigrationsV1(pool: Pool, + options: { readonly schema?: string; readonly createSchema?: boolean } = {}) { + const up = await readFile(new URL('../migrations/001_action_admission.up.sql', import.meta.url), 'utf8'); + const down = await readFile(new URL('../migrations/001_action_admission.down.sql', import.meta.url), 'utf8'); + return runPostgresMigrations(pool, { + applicationId: '@agentplat/collective-control-postgres/action-admission', + schema: options.schema, createSchema: options.createSchema, + migrations: [{ version: 1, name: '001_action_admission', up, down, destructiveDown: true }], + }); +} +/** Row locking serializes fence changes, shared budgets and receipts per tenant. */ +export class PostgresActionAdmissionStoreV1 implements ActionAdmissionStoreV1 { + private readonly table: string; + private readonly approvalTable: string; + readonly tenantId: string; + constructor(private readonly pool: Pool, options: { readonly schema?: string; readonly tenantId: string }) { + if (!options.tenantId.trim()) throw new TypeError('admission_tenant_required'); + this.tenantId = options.tenantId; + const prefix = quotePostgresIdentifier(normalizePostgresIdentifier(options.schema ?? defaultPostgresSchema, 'schema')); + this.table = `${prefix}.action_admission_states_v1`; + this.approvalTable = `${prefix}.action_approvals_v1`; + } + async transaction(tenantId: string, operation: (state: ActionAdmissionStateV1) => { + readonly state: ActionAdmissionStateV1; readonly result: T; + }, approval?: ActionApprovalAdmissionGuardV1): Promise { + if (tenantId !== this.tenantId) throw new Error('admission_scope_mismatch'); + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + const initial: ActionAdmissionStateV1 = { tenantId, highWaterMs: 0, fences: [], budgets: [], effects: [] }; + await client.query(`INSERT INTO ${this.table} (tenant_id, record, record_digest) + VALUES ($1,$2::jsonb,$3) ON CONFLICT (tenant_id) DO NOTHING`, + [tenantId, JSON.stringify(initial), controlDigest('grant', initial as unknown as ControlJson)]); + const { rows } = await client.query<{ record: ActionAdmissionStateV1; record_digest: string }>( + `SELECT record, record_digest FROM ${this.table} WHERE tenant_id=$1 FOR UPDATE`, [tenantId]); + const current = rows[0]; + validateActionAdmissionStateV1(current.record); + if (current.record.tenantId !== tenantId || controlDigest('grant', current.record as unknown as ControlJson) !== current.record_digest) + throw new Error('admission_state_corrupt'); + if (approval) { + const result = await client.query<{ record: ActionApprovalRecordV1; record_digest: string }>( + `SELECT record, record_digest FROM ${this.approvalTable} WHERE tenant_id=$1 AND approval_id=$2 FOR UPDATE`, + [tenantId, approval.approvalId]); + const row = result.rows[0]; + if (row && controlDigest('grant', row.record as unknown as ControlJson) !== row.record_digest) + throw new Error('action_approval_corrupt'); + assertActionApprovalAdmissionV1(row?.record, tenantId, approval); + } + const next = operation(structuredClone(current.record)); + validateActionAdmissionStateV1(next.state); + if (next.state.tenantId !== tenantId) throw new Error('admission_scope_mismatch'); + await client.query(`UPDATE ${this.table} SET record=$2::jsonb, record_digest=$3 WHERE tenant_id=$1`, + [tenantId, JSON.stringify(next.state), controlDigest('grant', next.state as unknown as ControlJson)]); + await client.query('COMMIT'); + return structuredClone(next.result); + } catch (error) { await client.query('ROLLBACK'); throw error; } + finally { client.release(); } + } +} diff --git a/packages/collective-control-postgres/src/action-approvals.ts b/packages/collective-control-postgres/src/action-approvals.ts new file mode 100644 index 00000000..76c0d362 --- /dev/null +++ b/packages/collective-control-postgres/src/action-approvals.ts @@ -0,0 +1,64 @@ +import { readFile } from 'node:fs/promises'; +import type { Pool } from 'pg'; +import { + defaultPostgresSchema, normalizePostgresIdentifier, quotePostgresIdentifier, runPostgresMigrations, +} from '@agentplat/postgres'; +import { + validateActionApprovalRecordV1, validateActionApprovalTransitionV1, + type ActionApprovalRecordV1, type ActionApprovalRepositoryV1, +} from '@agentplat/inference-control/action-approvals'; +import { controlDigest, type ControlJson } from '@agentplat/inference-control/tools'; + +/** Separate opt-in migration ledger: existing collective migrations are unchanged. */ +export async function runActionApprovalMigrationsV1(pool: Pool, options: { + readonly schema?: string; readonly createSchema?: boolean; +} = {}) { + const up = await readFile(new URL('../migrations/001_action_approvals.up.sql', import.meta.url), 'utf8'); + const down = await readFile(new URL('../migrations/001_action_approvals.down.sql', import.meta.url), 'utf8'); + return runPostgresMigrations(pool, { applicationId: '@agentplat/collective-control-postgres/action-approvals', + schema: options.schema, createSchema: options.createSchema, + migrations: [{ version: 1, name: '001_action_approvals', up, down, destructiveDown: true }] }); +} +function digest(value: unknown): string { return controlDigest('grant', value as ControlJson); } +export class PostgresActionApprovalRepositoryV1 implements ActionApprovalRepositoryV1 { + private readonly table: string; + readonly tenantId: string; + constructor(private readonly pool: Pool, options: { readonly tenantId: string; readonly schema?: string }) { + if (!options.tenantId.trim()) throw new TypeError('approval_tenant_required'); + this.tenantId = options.tenantId; + this.table = `${quotePostgresIdentifier(normalizePostgresIdentifier(options.schema ?? defaultPostgresSchema, 'schema'))}.action_approvals_v1`; + } + async create(record: ActionApprovalRecordV1): Promise { + validateActionApprovalRecordV1(record); + const copy = structuredClone(record); + if (copy.tenantId !== this.tenantId || copy.status !== 'pending') throw new Error('approval_scope_mismatch'); + await this.pool.query(`INSERT INTO ${this.table} (tenant_id, approval_id, record, record_digest) + VALUES ($1,$2,$3::jsonb,$4) ON CONFLICT (tenant_id, approval_id) DO NOTHING`, + [this.tenantId, copy.approvalId, JSON.stringify(copy), digest(copy)]); + const retained = await this.load(this.tenantId, copy.approvalId); + if (!retained) throw new Error('approval_store_unavailable'); + const initial = { ...retained, revision: 1, status: 'pending', decidedBy: null, decidedAtMs: null, boundEffectDigest: null, observedAtMs: retained.createdAtMs, closedAtMs: null }; + if (digest(initial) !== digest(copy)) throw new Error('approval_identity_conflict'); + return retained; + } + async load(tenantId: string, approvalId: string): Promise { + if (tenantId !== this.tenantId) throw new Error('approval_scope_mismatch'); + const result = await this.pool.query<{ record: ActionApprovalRecordV1; record_digest: string }>( + `SELECT record, record_digest FROM ${this.table} WHERE tenant_id=$1 AND approval_id=$2`, [tenantId, approvalId]); + const row = result.rows[0]; + if (!row) return undefined; + validateActionApprovalRecordV1(row.record); + if (row.record.tenantId !== tenantId || row.record.approvalId !== approvalId || digest(row.record) !== row.record_digest) + throw new Error('approval_store_corrupt'); + return structuredClone(row.record); + } + async compareAndSwap(expected: ActionApprovalRecordV1, next: ActionApprovalRecordV1): Promise { + const old = structuredClone(expected), replacement = structuredClone(next); + validateActionApprovalTransitionV1(old, replacement); + if (old.tenantId !== this.tenantId) throw new Error('approval_scope_mismatch'); + const result = await this.pool.query(`UPDATE ${this.table} SET record=$4::jsonb, record_digest=$5 + WHERE tenant_id=$1 AND approval_id=$2 AND record_digest=$3 AND record=$6::jsonb`, + [this.tenantId, old.approvalId, digest(old), JSON.stringify(replacement), digest(replacement), JSON.stringify(old)]); + return result.rowCount === 1; + } +} diff --git a/packages/inference-control/README.md b/packages/inference-control/README.md index 40f6ea90..1609316d 100644 --- a/packages/inference-control/README.md +++ b/packages/inference-control/README.md @@ -502,3 +502,35 @@ durable idempotency, logical time, current policy binding and any model-specific security properties. The package exposes no scheduler or global agent graph. See [ADR 0042](../../docs/adr/0042-collective-capability-closure.md) and the [architecture and threat model](../../docs/security/collective-capability-closure-v1.md). + +## Preparing action grants (additive source API) + +`createActionGrantV1` from `@agentplat/inference-control/tools` prepares an immutable +V1 grant from a scope, binding, input, explicit assessment references, idempotency +key and timestamps. It computes the existing scope/input/action digests and validates +references and the existing maximum 120-second lifetime. It does not generate IDs, +authenticate a caller, evaluate policy, approve an action or issue the grant. + +Trusted hosts must resolve identity and authorization, supply verified assessment +references, then call `issueActionGrantV1` with their existing repository. The gateway +still applies its current authority, assessment and execution checks. Manual grant +construction and existing APIs retain their behavior. This addition is source-only +until included in a published coordinated release; it is not present in npm 1.1.0. + +The new opt-in `./action-approvals` source entry point provides exact-target +approval evidence and an assessment wrapper; see [standalone action control](../../docs/action-control/README.md). +It is not part of published npm 1.1.0. The composed profile remains in progress. + +The opt-in `./action-admission` source entry point composes transactional resource +reservations and agent/connector/organization revocation fences with the existing +ActionGateway dispatcher. See the standalone action-control guide for trusted-host +requirements, accounting semantics and remaining qualification. + +`./action-effects` offers an explicit conditional-execution adapter contract for +external systems that atomically enforce reviewed resource preconditions and +retain idempotent receipts. Unsupported destinations must use a weaker, clearly +identified profile; read-before-write is not an atomic guarantee. + +`recoverReservedActionGrantV1` conservatively transitions a durable reserved grant +to indeterminate only after a trusted host verifies its original worker stopped +or was fenced. It never reissues or redispatches. Reconciliation remains separate. diff --git a/packages/inference-control/package.json b/packages/inference-control/package.json index 3ca2665f..b65ae3f6 100644 --- a/packages/inference-control/package.json +++ b/packages/inference-control/package.json @@ -103,6 +103,18 @@ "./tools": { "types": "./dist/tools.d.ts", "import": "./dist/tools.js" + }, + "./action-approvals": { + "types": "./dist/action-approvals.d.ts", + "import": "./dist/action-approvals.js" + }, + "./action-admission": { + "types": "./dist/action-admission.d.ts", + "import": "./dist/action-admission.js" + }, + "./action-effects": { + "types": "./dist/action-effects.d.ts", + "import": "./dist/action-effects.js" } }, "files": [ diff --git a/packages/inference-control/src/action-admission.ts b/packages/inference-control/src/action-admission.ts new file mode 100644 index 00000000..0ff8f64f --- /dev/null +++ b/packages/inference-control/src/action-admission.ts @@ -0,0 +1,266 @@ +import { validateActionApprovalRecordV1, type ActionApprovalRecordV1, type InMemoryActionApprovalRepositoryV1 } from './action-approvals.js'; +import type { ActionDispatcher, ControlJson } from './tools.js'; +import { actionInputDigest, controlDigest, boundedCanonicalControlJsonV1 } from './tools.js'; + +export interface ActionRevocationFenceV1 { + readonly kind: 'agent' | 'connector' | 'organization'; + readonly id: string; + readonly epoch: number; + readonly active: boolean; +} +export interface ActionBudgetAccountV1 { + readonly accountId: string; + readonly unit: string; + readonly periodStartMs: number; + readonly periodEndMs: number; + readonly maximumUnits: number; + readonly revision: number; +} +export interface ActionBudgetChargeV1 { + readonly accountId: string; + readonly units: number; + readonly accountRevision: number; + readonly unit: string; +} +export interface ActionApprovalAdmissionGuardV1 { + readonly approvalId: string; + readonly targetDigest: string; + readonly boundEffectDigest: string; + readonly nowMs: number; +} +export interface ActionAdmissionRequestV1 { + readonly tenantId: string; + readonly effectId: string; + readonly gatewayId: string; + readonly scopeDigest: string; + readonly grantId: string; + readonly dispatchAttemptId: string; + readonly idempotencyKey: string; + readonly actionDigest: string; + readonly inputDigest: string; + readonly fences: readonly ActionRevocationFenceV1[]; + readonly charges: readonly ActionBudgetChargeV1[]; + readonly nowMs: number; + readonly approval: { readonly approvalId: string; readonly targetDigest: string } | null; +} +export interface ActionEffectReceiptV1 { + readonly request: ActionAdmissionRequestV1; + /** Includes all bound facts except observation time, which may advance on retry. */ + readonly requestDigest: string; + readonly status: 'indeterminate' | 'succeeded' | 'not_applied'; + readonly proofRef: string | null; +} +export interface ActionAdmissionStateV1 { + readonly tenantId: string; + readonly highWaterMs: number; + readonly fences: readonly ActionRevocationFenceV1[]; + readonly budgets: readonly { readonly account: ActionBudgetAccountV1; readonly usedUnits: number }[]; + readonly effects: readonly ActionEffectReceiptV1[]; +} +/** The same tenant serialization boundary must cover configuration and reservations. */ +export interface ActionAdmissionStoreV1 { + transaction(tenantId: string, operation: (state: ActionAdmissionStateV1) => { + readonly state: ActionAdmissionStateV1; readonly result: T; + }, approval?: ActionApprovalAdmissionGuardV1): Promise; +} +/** Called under the same storage lock/transaction as effect admission. */ +export function assertActionApprovalAdmissionV1(record: ActionApprovalRecordV1 | undefined, + tenantId: string, guard: ActionApprovalAdmissionGuardV1): void { + if (!record) throw new Error('action_approval_missing'); + validateActionApprovalRecordV1(record); + if (record.tenantId !== tenantId || record.approvalId !== guard.approvalId || + record.status !== 'approved' || record.targetDigest !== guard.targetDigest || + record.boundEffectDigest !== guard.boundEffectDigest || guard.nowMs < record.observedAtMs || + guard.nowMs >= record.expiresAtMs) throw new Error('action_approval_stale'); +} +function integer(value: number, positive = false): boolean { + return Number.isSafeInteger(value) && value >= (positive ? 1 : 0); +} +function nonempty(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } +function fenceKey(fence: ActionRevocationFenceV1): string { return JSON.stringify([fence.kind, fence.id]); } +export function actionAdmissionRequestDigestV1(request: ActionAdmissionRequestV1): string { + const { nowMs: _, ...bound } = request; + return controlDigest('grant', bound as unknown as ControlJson); +} +function validateFence(fence: ActionRevocationFenceV1): void { + if (!['agent','connector','organization'].includes(fence.kind) || !nonempty(fence.id) || + !integer(fence.epoch, true) || typeof fence.active !== 'boolean') throw new Error('invalid_action_fence'); +} +function validateAccount(account: ActionBudgetAccountV1): void { + if (!nonempty(account.accountId) || !nonempty(account.unit) || !integer(account.revision, true) || + !integer(account.maximumUnits) || !integer(account.periodStartMs) || !integer(account.periodEndMs) || + account.periodEndMs <= account.periodStartMs) throw new Error('invalid_action_budget'); +} +function validateRequest(request: ActionAdmissionRequestV1): void { + if (![request.tenantId, request.effectId, request.gatewayId, request.grantId, request.dispatchAttemptId, request.idempotencyKey].every(nonempty) || + ![request.actionDigest, request.inputDigest, request.scopeDigest].every(x => /^sha256:[0-9a-f]{64}$/.test(x)) || + (request.approval !== null && (!request.approval || !nonempty(request.approval.approvalId) || + !/^sha256:[0-9a-f]{64}$/.test(request.approval.targetDigest))) || + !integer(request.nowMs) || !Array.isArray(request.fences) || request.fences.length !== 3 || + new Set(request.fences.map(x => x.kind)).size !== 3 || + !Array.isArray(request.charges) || new Set(request.charges.map(x => x.accountId)).size !== request.charges.length) + throw new Error('invalid_action_admission'); + request.fences.forEach(validateFence); + for (const charge of request.charges) + if (!nonempty(charge.accountId) || !nonempty(charge.unit) || !integer(charge.units) || !integer(charge.accountRevision, true)) + throw new Error('invalid_action_charge'); + boundedCanonicalControlJsonV1(request, 65_536, 'action_not_permitted'); +} +export function validateActionAdmissionStateV1(state: ActionAdmissionStateV1): void { + if (!nonempty(state.tenantId) || !integer(state.highWaterMs)) throw new Error('invalid_admission_state'); + const keys = state.fences.map(fenceKey); + if (new Set(keys).size !== keys.length) throw new Error('invalid_admission_state'); + state.fences.forEach(validateFence); + if (new Set(state.budgets.map(x => x.account.accountId)).size !== state.budgets.length || + new Set(state.effects.map(x => x.request.effectId)).size !== state.effects.length) + throw new Error('invalid_admission_state'); + for (const budget of state.budgets) { validateAccount(budget.account); if (!integer(budget.usedUnits)) throw new Error('invalid_admission_state'); } + // Reconstruct accounting, including unknown effects, to reject forged refunds. + const totals = new Map(); + for (const effect of state.effects) { + validateRequest(effect.request); + if (effect.request.tenantId !== state.tenantId || effect.request.nowMs > state.highWaterMs || + effect.requestDigest !== actionAdmissionRequestDigestV1(effect.request) || + !['indeterminate','succeeded','not_applied'].includes(effect.status) || + (effect.status === 'indeterminate' ? effect.proofRef !== null : !nonempty(effect.proofRef))) + throw new Error('invalid_admission_state'); + for (const charge of effect.request.charges) { + if (!state.budgets.some(x => x.account.accountId === charge.accountId)) throw new Error('invalid_admission_state'); + if (effect.status !== 'not_applied') totals.set(charge.accountId, (totals.get(charge.accountId) ?? 0) + charge.units); + } + } + for (const budget of state.budgets) + if (budget.usedUnits !== (totals.get(budget.account.accountId) ?? 0)) throw new Error('invalid_admission_state'); +} +/** Ephemeral only. Serializes asynchronous callers using one queue per tenant. */ +export class InMemoryActionAdmissionStoreV1 implements ActionAdmissionStoreV1 { + constructor(private readonly approvals?: InMemoryActionApprovalRepositoryV1) {} + private readonly states = new Map(); + private readonly queues = new Map>(); + transaction(tenantId: string, operation: (state: ActionAdmissionStateV1) => { state: ActionAdmissionStateV1; result: T }, approval?: ActionApprovalAdmissionGuardV1): Promise { + const task = (this.queues.get(tenantId) ?? Promise.resolve()).catch(() => {}).then(() => { + const current = this.states.get(tenantId) ?? { tenantId, highWaterMs: 0, fences: [], budgets: [], effects: [] }; + if (approval) assertActionApprovalAdmissionV1(this.approvals?.readForAdmission(tenantId, approval.approvalId), tenantId, approval); + const next = operation(structuredClone(current)); + if (next.state.tenantId !== tenantId) throw new Error('admission_scope_mismatch'); + validateActionAdmissionStateV1(next.state); + this.states.set(tenantId, structuredClone(next.state)); + return structuredClone(next.result); + }); + this.queues.set(tenantId, task); return task; + } +} +export interface ActionEffectProofV1 { + readonly requestDigest: string; + readonly outcome: 'succeeded' | 'not_applied'; + readonly proofRef: string; +} +export class ActionAdmissionServiceV1 { + constructor(readonly store: ActionAdmissionStoreV1) {} + /** Trusted administrative host only; never expose directly to agent input. */ + async configureFence(tenantId: string, fence: ActionRevocationFenceV1): Promise { + const candidate = structuredClone(fence); validateFence(candidate); + await this.store.transaction(tenantId, state => { + const previous = state.fences.find(x => fenceKey(x) === fenceKey(candidate)); + if (previous && candidate.epoch <= previous.epoch) throw new Error('fence_epoch_must_advance'); + return { state: { ...state, fences: [...state.fences.filter(x => fenceKey(x) !== fenceKey(candidate)), candidate] }, result: undefined }; + }); + } + /** Account IDs include scope and immutable period; renewal must create a new ID. */ + async configureBudget(tenantId: string, account: ActionBudgetAccountV1): Promise { + const candidate = structuredClone(account); validateAccount(candidate); + await this.store.transaction(tenantId, state => { + const old = state.budgets.find(x => x.account.accountId === candidate.accountId); + if (old && (candidate.revision <= old.account.revision || candidate.unit !== old.account.unit || + candidate.periodStartMs !== old.account.periodStartMs || candidate.periodEndMs !== old.account.periodEndMs)) + throw new Error('budget_revision_or_period_conflict'); + return { state: { ...state, budgets: [...state.budgets.filter(x => x.account.accountId !== candidate.accountId), + { account: candidate, usedUnits: old?.usedUnits ?? 0 }] }, result: undefined }; + }); + } + async reserve(request: ActionAdmissionRequestV1): Promise<{ readonly status: 'admitted' | 'replayed'; readonly receipt: ActionEffectReceiptV1 }> { + const candidate = structuredClone(request); validateRequest(candidate); + const guard = candidate.approval ? { ...candidate.approval, nowMs: candidate.nowMs, + boundEffectDigest: controlDigest('grant', { grantId: candidate.grantId, actionDigest: candidate.actionDigest, + scopeDigest: candidate.scopeDigest, idempotencyKey: candidate.idempotencyKey }) } : undefined; + return this.store.transaction<{ status: 'admitted' | 'replayed'; receipt: ActionEffectReceiptV1 }>(candidate.tenantId, state => { + if (candidate.nowMs < state.highWaterMs) throw new Error('admission_time_rollback'); + const requestDigest = actionAdmissionRequestDigestV1(candidate); + const old = state.effects.find(x => x.request.effectId === candidate.effectId); + if (old) { + if (old.requestDigest !== requestDigest) throw new Error('effect_identity_conflict'); + return { state: { ...state, highWaterMs: candidate.nowMs }, result: { status: 'replayed' as const, receipt: old } }; + } + if (state.effects.some(x => + (x.request.gatewayId === candidate.gatewayId && + (x.request.grantId === candidate.grantId || x.request.dispatchAttemptId === candidate.dispatchAttemptId)) || + (x.request.scopeDigest === candidate.scopeDigest && x.request.idempotencyKey === candidate.idempotencyKey))) + throw new Error('effect_dispatch_identity_conflict'); + for (const expected of candidate.fences) { + const actual = state.fences.find(x => fenceKey(x) === fenceKey(expected)); + if (!actual || !actual.active || !expected.active || actual.epoch !== expected.epoch) throw new Error('action_fence_stale'); + } + const budgets = state.budgets.map(x => ({ ...x })); + for (const charge of candidate.charges) { + const budget = budgets.find(x => x.account.accountId === charge.accountId); + if (!budget || budget.account.unit !== charge.unit || budget.account.revision !== charge.accountRevision || candidate.nowMs < budget.account.periodStartMs || + candidate.nowMs >= budget.account.periodEndMs || !integer(budget.usedUnits + charge.units) || + budget.usedUnits + charge.units > budget.account.maximumUnits) throw new Error('action_budget_exhausted_or_stale'); + budget.usedUnits += charge.units; + } + const receipt: ActionEffectReceiptV1 = { request: candidate, requestDigest, status: 'indeterminate', proofRef: null }; + return { state: { ...state, highWaterMs: candidate.nowMs, budgets, effects: [...state.effects, receipt] }, + result: { status: 'admitted' as const, receipt } }; + }, guard); + } + /** Resolver must prove not_applied means the admitted attempt cannot later apply. */ + async reconcile(tenantId: string, effectId: string, + resolve: (receipt: ActionEffectReceiptV1) => Promise): Promise { + const initial = await this.store.transaction(tenantId, state => { + const receipt = state.effects.find(x => x.request.effectId === effectId); + if (!receipt) throw new Error('unknown_action_effect'); + return { state, result: receipt }; + }); + const proof = await resolve(structuredClone(initial)); + if (!proof || proof.requestDigest !== initial.requestDigest || !nonempty(proof.proofRef) || + !['succeeded','not_applied'].includes(proof.outcome)) throw new Error('action_reconciliation_unverified'); + return this.store.transaction(tenantId, state => { + const current = state.effects.find(x => x.request.effectId === effectId)!; + if (current.status !== 'indeterminate') { + if (current.status !== proof.outcome) throw new Error('action_reconciliation_conflict'); + return { state, result: current }; + } + const next = { ...current, status: proof.outcome, proofRef: proof.proofRef }; + const budgets = state.budgets.map(x => ({ ...x })); + if (proof.outcome === 'not_applied') for (const charge of current.request.charges) { + const budget = budgets.find(x => x.account.accountId === charge.accountId)!; + budget.usedUnits -= charge.units; + } + return { state: { ...state, budgets, effects: state.effects.map(x => x.request.effectId === effectId ? next : x) }, result: next }; + }); + } +} +/** Applies admission after the existing gateway checks, without issuing grants. */ +export function createActionAdmissionDispatcherV1(options: { + readonly downstream: ActionDispatcher; + readonly service: ActionAdmissionServiceV1; + /** Trusted quote must include all applicable resource accounts and fences. */ + readonly quote: (input: Parameters[0]) => Promise; +}): ActionDispatcher { + const downstream = options.downstream, dispatch = downstream.dispatch.bind(downstream); + return Object.freeze({ dispatcherId: downstream.dispatcherId, dispatcherVersion: downstream.dispatcherVersion, + fencingMode: downstream.fencingMode, + async dispatch(input: Parameters[0]) { + const request = await options.quote(input); + if (request.tenantId !== input.context.tenant.tenantId || request.grantId !== input.permit.grantId || + request.dispatchAttemptId !== input.permit.dispatchAttemptId || request.idempotencyKey !== input.permit.idempotencyKey || + request.gatewayId !== input.permit.gatewayId || request.scopeDigest !== input.permit.scopeDigest || + request.actionDigest !== input.permit.actionDigest || request.inputDigest !== actionInputDigest(input.input)) + throw new Error('action_quote_mismatch'); + const admission = await options.service.reserve(request); + if (admission.status === 'replayed') throw new Error('action_effect_already_admitted'); + // Unknown outcomes retain all accounting. A trusted receipt resolver settles later. + return dispatch(input); + }, + }); +} diff --git a/packages/inference-control/src/action-approvals.ts b/packages/inference-control/src/action-approvals.ts new file mode 100644 index 00000000..8ab651df --- /dev/null +++ b/packages/inference-control/src/action-approvals.ts @@ -0,0 +1,313 @@ +import { + actionInputDigest, controlDigest, scopeDigest, boundedCanonicalControlJsonV1, + type ActionBinding, type ActionScope, type ActionGrant, + isActionScopeV1, type ActionAssessmentResolver, type ControlJson, type ControlJsonObject, +} from './tools.js'; + +/** Exact reviewed target. Facts and fences must be resolved by a trusted host. */ +export interface ActionApprovalTargetV1 { + readonly schemaVersion: 1; + readonly scope: ActionScope; + readonly binding: ActionBinding; + readonly inputDigest: string; + readonly preconditionsDigest: string; + readonly authorityDigest: string; + readonly policyDigest: string; +} +export interface ActionApprovalRecordV1 { + readonly schemaVersion: 1; + readonly approvalId: string; + readonly tenantId: string; + readonly requestedBy: string; + readonly target: ActionApprovalTargetV1; + readonly targetDigest: string; + readonly revision: number; + readonly observedAtMs: number; + readonly closedAtMs: number | null; + readonly boundEffectDigest: string | null; + readonly createdAtMs: number; + readonly expiresAtMs: number; + readonly status: 'pending' | 'approved' | 'rejected' | 'expired' | 'invalidated'; + readonly decidedBy: string | null; + readonly decidedAtMs: number | null; +} +/** Implementations must scope every operation by tenant and apply CAS atomically. */ +export interface ActionApprovalRepositoryV1 { + create(record: ActionApprovalRecordV1): Promise; + load(tenantId: string, approvalId: string): Promise; + compareAndSwap(expected: ActionApprovalRecordV1, next: ActionApprovalRecordV1): Promise; +} +export interface ActionApprovalPrincipalV1 { + readonly tenantId: string; + readonly actorId: string; + readonly kind: 'person' | 'agent'; +} +export interface ActionApprovalAccessV1 { + /** Must authenticate context; user-supplied IDs are never sufficient. */ + resolve(context: Context): Promise; + canRequest(principal: ActionApprovalPrincipalV1, target: ActionApprovalTargetV1): Promise; + canDecide(principal: ActionApprovalPrincipalV1, record: ActionApprovalRecordV1): Promise; +} +const digestPattern = /^sha256:[0-9a-f]{64}$/; +function time(value: number): void { + if (!Number.isSafeInteger(value) || value < 0) throw new TypeError('invalid_approval_time'); +} +function text(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} +function immutable(value: T): T { + const copy = structuredClone(value); + function freeze(v: unknown): void { + if (v && typeof v === 'object') { + for (const item of Object.values(v)) freeze(item); + Object.freeze(v); + } + } + freeze(copy); + return copy; +} +export function actionApprovalTargetDigestV1(target: ActionApprovalTargetV1): string { + // The gateway's scope and binding checks remain authoritative; this digest + // includes ALL binding dependencies plus host facts, not merely a tool name. + return controlDigest('grant', target as unknown as ControlJson); +} +export function createActionApprovalTargetV1(input: { + readonly scope: ActionScope; readonly binding: ActionBinding; + readonly input: ControlJsonObject; readonly preconditions: ControlJsonObject; + readonly authority: ControlJsonObject; readonly policy: ControlJsonObject; +}): ActionApprovalTargetV1 { + boundedCanonicalControlJsonV1(input.input, 65_536, 'action_not_permitted'); + for (const facts of [input.preconditions, input.authority, input.policy]) + boundedCanonicalControlJsonV1(facts, 65_536, 'action_not_permitted'); + const target = immutable({ schemaVersion: 1 as const, scope: input.scope, + binding: input.binding, inputDigest: actionInputDigest(input.input), + preconditionsDigest: controlDigest('grant', input.preconditions), + authorityDigest: controlDigest('grant', input.authority), + policyDigest: controlDigest('grant', input.policy) }); + assertTarget(target); + return target; +} +function assertTarget(target: ActionApprovalTargetV1): void { + if (target.schemaVersion !== 1 || !isActionScopeV1(target.scope) || !text(target.scope.tenantId) || + !text(target.scope.agentId) || target.binding.schemaVersion !== 1 || + ![target.binding.actionBindingId, target.binding.namespace, target.binding.toolId, + target.binding.operation, target.binding.dispatcherId, target.binding.contextResolverId].every(text) || + ![target.binding.actionBindingVersion, target.binding.dispatcherVersion, + target.binding.contextResolverVersion].every(x => Number.isSafeInteger(x) && x > 0) || + !digestPattern.test(target.binding.handlerDigest) || + !['local_only','downstream_atomic'].includes(target.binding.fencingMode) || + ![target.inputDigest, target.preconditionsDigest, target.authorityDigest, + target.policyDigest].every(x => digestPattern.test(x))) + throw new TypeError('invalid_approval_target'); + // Use the existing canonicalizer to reject non-JSON or unbounded records. + boundedCanonicalControlJsonV1(target, 65_536, 'action_not_permitted'); +} +export function validateActionApprovalRecordV1(record: ActionApprovalRecordV1): void { + assertTarget(record.target); + time(record.createdAtMs); time(record.expiresAtMs); time(record.observedAtMs); + if (record.schemaVersion !== 1 || !text(record.approvalId) || !text(record.requestedBy) || + record.tenantId !== record.target.scope.tenantId || + record.targetDigest !== actionApprovalTargetDigestV1(record.target) || + !Number.isSafeInteger(record.revision) || record.revision < 1 || + record.observedAtMs < record.createdAtMs || record.expiresAtMs <= record.createdAtMs || + !['pending','approved','rejected','expired','invalidated'].includes(record.status) || + (record.boundEffectDigest !== null && !digestPattern.test(record.boundEffectDigest))) + throw new TypeError('invalid_approval_record'); + if (['pending','approved'].includes(record.status) && record.observedAtMs >= record.expiresAtMs) + throw new TypeError('invalid_approval_record'); + const decided = record.decidedAtMs !== null; + if (decided) { + time(record.decidedAtMs!); + if (!text(record.decidedBy) || record.decidedBy === record.requestedBy || + record.decidedBy === record.target.scope.agentId || record.decidedAtMs! < record.createdAtMs || + record.decidedAtMs! >= record.expiresAtMs || record.decidedAtMs! > record.observedAtMs) + throw new TypeError('invalid_approval_record'); + } else if (record.decidedBy !== null) throw new TypeError('invalid_approval_record'); + if (['approved','rejected'].includes(record.status) && !decided) + throw new TypeError('invalid_approval_record'); + if (record.status === 'pending' && (decided || record.boundEffectDigest !== null)) + throw new TypeError('invalid_approval_record'); + if (['expired','invalidated'].includes(record.status)) { + if (record.closedAtMs === null) throw new TypeError('invalid_approval_record'); + time(record.closedAtMs); + if (record.closedAtMs < record.createdAtMs || record.closedAtMs > record.observedAtMs || + (record.status === 'expired' && record.closedAtMs < record.expiresAtMs)) + throw new TypeError('invalid_approval_record'); + } else if (record.closedAtMs !== null) throw new TypeError('invalid_approval_record'); + if (record.revision === 1 && (record.status !== 'pending' || record.observedAtMs !== record.createdAtMs)) + throw new TypeError('invalid_approval_record'); +} +export class InMemoryActionApprovalRepositoryV1 implements ActionApprovalRepositoryV1 { + private readonly records = new Map(); + private key(tenant: string, id: string): string { return JSON.stringify([tenant, id]); } + async create(record: ActionApprovalRecordV1): Promise { + validateActionApprovalRecordV1(record); + if (record.status !== 'pending') throw new Error('approval_must_start_pending'); + const key = this.key(record.tenantId, record.approvalId), old = this.records.get(key); + if (old) { + // Return the original lifecycle state on an exact request retry. + const initial = { ...old, revision: 1, status: 'pending', decidedBy: null, decidedAtMs: null, boundEffectDigest: null, observedAtMs: old.createdAtMs, closedAtMs: null }; + if (controlDigest('grant', initial as unknown as ControlJson) !== + controlDigest('grant', record as unknown as ControlJson)) throw new Error('approval_identity_conflict'); + return immutable(old); + } + this.records.set(key, immutable(record)); return immutable(record); + } + /** Synchronous critical-section read for the in-memory admission composition. */ + readForAdmission(tenantId: string, approvalId: string): ActionApprovalRecordV1 | undefined { + const record = this.records.get(this.key(tenantId, approvalId)); + return record ? immutable(record) : undefined; + } + async load(tenantId: string, approvalId: string): Promise { + const record = this.records.get(this.key(tenantId, approvalId)); + return record ? immutable(record) : undefined; + } + async compareAndSwap(expected: ActionApprovalRecordV1, next: ActionApprovalRecordV1): Promise { + validateActionApprovalTransitionV1(expected, next); + const key = this.key(expected.tenantId, expected.approvalId), old = this.records.get(key); + if (!old || controlDigest('grant', old as unknown as ControlJson) !== + controlDigest('grant', expected as unknown as ControlJson)) return false; + this.records.set(key, immutable(next)); return true; + } +} +export function validateActionApprovalTransitionV1(old: ActionApprovalRecordV1, next: ActionApprovalRecordV1): void { + validateActionApprovalRecordV1(old); validateActionApprovalRecordV1(next); + const lifecycleAllowed = + (old.status === 'pending' && ['pending','approved','rejected','expired','invalidated'].includes(next.status)) || + (old.status === 'approved' && ['approved','expired','invalidated'].includes(next.status)); + if (next.revision !== old.revision + 1 || next.approvalId !== old.approvalId || + next.tenantId !== old.tenantId || next.requestedBy !== old.requestedBy || + next.targetDigest !== old.targetDigest || next.createdAtMs !== old.createdAtMs || + next.expiresAtMs !== old.expiresAtMs || next.observedAtMs < old.observedAtMs || !lifecycleAllowed || + (next.closedAtMs !== null && next.closedAtMs < old.observedAtMs) || + (old.status === 'pending' && next.boundEffectDigest !== null) || + (old.boundEffectDigest !== null && next.boundEffectDigest !== old.boundEffectDigest) || + (old.status !== 'pending' && (old.decidedBy !== next.decidedBy || old.decidedAtMs !== next.decidedAtMs))) + throw new Error('invalid_approval_transition'); +} +/** Host clock only: persists a monotonic observation and irreversible expiry. */ +export async function observeActionApprovalV1(repository: ActionApprovalRepositoryV1, + tenantId: string, approvalId: string, nowMs: number): Promise { + time(nowMs); + for (let attempt = 0; attempt < 8; attempt++) { + const record = await repository.load(tenantId, approvalId); + if (!record) return undefined; + validateActionApprovalRecordV1(record); + if (nowMs < record.observedAtMs) throw new Error('approval_time_rollback'); + if (!['pending','approved'].includes(record.status)) return record; + const expired = nowMs >= record.expiresAtMs; + if (!expired && nowMs === record.observedAtMs) return record; + const next = { ...record, revision: record.revision + 1, observedAtMs: nowMs, + status: expired ? 'expired' as const : record.status, + closedAtMs: expired ? nowMs : null }; + if (await repository.compareAndSwap(record, next)) return immutable(next); + } + throw new Error('approval_conflict'); +} +export class ActionApprovalServiceV1 { + constructor(readonly repository: ActionApprovalRepositoryV1, + private readonly access: ActionApprovalAccessV1) {} + async request(context: Context, input: { + readonly approvalId: string; readonly target: ActionApprovalTargetV1; + readonly createdAtMs: number; readonly expiresAtMs: number; + }): Promise { + // Snapshot before asynchronous host callbacks. + const request = immutable(input); + const principal = await this.access.resolve(context); + if (!principal || principal.tenantId !== request.target.scope.tenantId || + !(await this.access.canRequest(principal, request.target))) throw new Error('approval_forbidden'); + return this.repository.create({ schemaVersion: 1, approvalId: request.approvalId, + tenantId: principal.tenantId, requestedBy: principal.actorId, target: request.target, + targetDigest: actionApprovalTargetDigestV1(request.target), revision: 1, + createdAtMs: request.createdAtMs, expiresAtMs: request.expiresAtMs, + status: 'pending', decidedBy: null, decidedAtMs: null, boundEffectDigest: null, + observedAtMs: request.createdAtMs, closedAtMs: null }); + } + async decide(context: Context, input: { + readonly tenantId: string; readonly approvalId: string; + readonly targetDigest: string; readonly decision: 'approved' | 'rejected'; readonly nowMs: number; + }): Promise { + const request = immutable(input); time(request.nowMs); + if (!['approved','rejected'].includes(request.decision)) throw new Error('invalid_approval_decision'); + const principal = await this.access.resolve(context); + const record = await this.repository.load(request.tenantId, request.approvalId); + if (!principal || !record || principal.tenantId !== record.tenantId || principal.kind !== 'person' || + principal.actorId === record.requestedBy || principal.actorId === record.target.scope.agentId || + !(await this.access.canDecide(principal, record))) throw new Error('approval_forbidden'); + if (record.targetDigest !== request.targetDigest) throw new Error('approval_target_mismatch'); + if (request.nowMs < record.observedAtMs) throw new Error('approval_time_rollback'); + if (request.nowMs >= record.expiresAtMs) { + await observeActionApprovalV1(this.repository, record.tenantId, record.approvalId, request.nowMs); + throw new Error('approval_expired'); + } + if (record.status !== 'pending') { + if (record.status === request.decision && record.decidedBy === principal.actorId) return record; + throw new Error('approval_not_pending'); + } + const next = { ...record, revision: record.revision + 1, status: request.decision, + decidedBy: principal.actorId, decidedAtMs: request.nowMs, observedAtMs: request.nowMs }; + if (!(await this.repository.compareAndSwap(record, next))) throw new Error('approval_conflict'); + return immutable(next); + } + async invalidate(context: Context, input: { + readonly tenantId: string; readonly approvalId: string; readonly targetDigest: string; readonly nowMs: number; + }): Promise { + const request = immutable(input); time(request.nowMs); + const principal = await this.access.resolve(context); + const record = await this.repository.load(request.tenantId, request.approvalId); + if (!principal || !record || principal.kind !== 'person' || principal.tenantId !== record.tenantId || + !(await this.access.canDecide(principal, record))) throw new Error('approval_forbidden'); + if (request.targetDigest !== record.targetDigest) throw new Error('approval_target_mismatch'); + if (request.nowMs < record.observedAtMs) throw new Error('approval_time_rollback'); + if (record.status === 'invalidated') return record; + const next = { ...record, status: 'invalidated' as const, revision: record.revision + 1, + observedAtMs: request.nowMs, closedAtMs: request.nowMs }; + if (!(await this.repository.compareAndSwap(record, next))) throw new Error('approval_conflict'); + return immutable(next); + } +} + +/** Narrows an existing assessment resolver; never replaces base authority/policy. */ +export function createActionApprovalAssessmentResolverV1(options: { + readonly base: ActionAssessmentResolver; readonly repository: ActionApprovalRepositoryV1; + readonly approvalId: (grant: ActionGrant) => Promise; + /** Re-read facts, policies and revocation fences from trusted sources. */ + readonly currentTarget: (grant: ActionGrant, nowMs: number) => Promise; +}): ActionAssessmentResolver { + return Object.freeze({ assessorId: options.base.assessorId, assessorVersion: options.base.assessorVersion, + async consumeCurrent(grant: ActionGrant, nowMs: number): Promise { + try { + time(nowMs); + if (!(await options.base.consumeCurrent(grant, nowMs))) return false; + const id = await options.approvalId(grant); + if (!id) return false; + const record = await observeActionApprovalV1(options.repository, grant.scope.tenantId, id, nowMs); + if (!record) return false; + validateActionApprovalRecordV1(record); + if (record.status !== 'approved' || nowMs < record.createdAtMs || nowMs >= record.expiresAtMs) return false; + const target = record.target; + if (scopeDigest(target.scope) !== grant.scopeDigest || target.inputDigest !== grant.inputDigest || + target.binding.actionBindingId !== grant.actionBindingId || + target.binding.actionBindingVersion !== grant.actionBindingVersion || + target.binding.handlerDigest !== grant.handlerDigest || + target.binding.namespace !== grant.namespace || target.binding.toolId !== grant.toolId || + target.binding.operation !== grant.operation || grant.assessmentTargetDigest !== record.targetDigest) return false; + const current = await options.currentTarget(grant, nowMs); + if (!current) return false; + assertTarget(current); + if (actionApprovalTargetDigestV1(current) !== record.targetDigest) { + await options.repository.compareAndSwap(record, { ...record, status: 'invalidated', + revision: record.revision + 1, closedAtMs: nowMs }); + return false; + } + const effectDigest = controlDigest('grant', { grantId: grant.grantId, + actionDigest: grant.actionDigest, scopeDigest: grant.scopeDigest, idempotencyKey: grant.idempotencyKey }); + if (record.boundEffectDigest !== null) return record.boundEffectDigest === effectDigest; + const next = { ...record, revision: record.revision + 1, boundEffectDigest: effectDigest }; + if (await options.repository.compareAndSwap(record, next)) return true; + const latest = await options.repository.load(record.tenantId, record.approvalId); + return latest?.status === 'approved' && latest.boundEffectDigest === effectDigest; + } catch { return false; } + }, + }); +} diff --git a/packages/inference-control/src/action-effects.ts b/packages/inference-control/src/action-effects.ts new file mode 100644 index 00000000..b5b3ac7a --- /dev/null +++ b/packages/inference-control/src/action-effects.ts @@ -0,0 +1,77 @@ +import type { ToolInvocationResult } from '@agentplat/tools'; +import { actionInputDigest, controlDigest, type ActionDispatcher, type ControlJsonObject } from './tools.js'; + +/** Host-attested adapter capabilities, not proof of an arbitrary external service. */ +export interface ActionExternalEffectCapabilitiesV1 { + readonly executorId: string; + readonly executorVersion: number; + readonly atomicPreconditions: boolean; + readonly idempotency: 'native' | 'durable_adapter' | 'none'; + readonly receiptLookup: boolean; +} +export interface ActionExternalEffectReceiptV1 { + readonly schemaVersion: 1; + readonly idempotencyKey: string; + readonly actionDigest: string; + readonly inputDigest: string; + readonly preconditionsDigest: string; + readonly outcome: 'succeeded' | 'not_applied'; + readonly proofRef: string; + readonly result: ToolInvocationResult; +} +export interface ActionConditionalExecutionPortV1 { + readonly capabilities: ActionExternalEffectCapabilitiesV1; + /** + * Apply preconditions and effect atomically at the destination, retaining an + * immutable receipt under the idempotency key. A not_applied receipt proves + * this attempt cannot apply later under that key. Throws mean uncertainty. + */ + execute(input: Parameters[0] & { + readonly preconditions: ControlJsonObject; + readonly preconditionsDigest: string; + }): Promise; +} +/** Exact conditional-write contract; it does not issue grants or reserve budgets. */ +export function createConditionalActionDispatcherV1(options: { + readonly dispatcherId: string; + readonly dispatcherVersion: number; + readonly fencingMode: 'local_only' | 'downstream_atomic'; + readonly port: ActionConditionalExecutionPortV1; + /** Load immutable reviewed constraints, not a newly read permissive baseline. */ + readonly resolvePreconditions: (input: Parameters[0]) => Promise<{ + readonly preconditions: ControlJsonObject; + readonly approvedPreconditionsDigest: string; + }>; +}): ActionDispatcher { + const capabilities = Object.freeze({ ...options.port.capabilities }); + if (!options.dispatcherId.trim() || !Number.isSafeInteger(options.dispatcherVersion) || options.dispatcherVersion < 1 || + !capabilities.executorId.trim() || !Number.isSafeInteger(capabilities.executorVersion) || capabilities.executorVersion < 1 || + capabilities.atomicPreconditions !== true || capabilities.idempotency === 'none' || + !['native','durable_adapter'].includes(capabilities.idempotency) || capabilities.receiptLookup !== true) + throw new Error('conditional_action_capabilities_required'); + const execute = options.port.execute.bind(options.port); + return Object.freeze({ dispatcherId: options.dispatcherId, dispatcherVersion: options.dispatcherVersion, + fencingMode: options.fencingMode, + async dispatch(input: Parameters[0]): Promise { + const constraints = await options.resolvePreconditions(input); + const preconditions = structuredClone(constraints.preconditions); + if (!preconditions || typeof preconditions !== 'object' || Array.isArray(preconditions)) + throw new Error('conditional_action_preconditions_mismatch'); + const preconditionsDigest = controlDigest('grant', preconditions); + if (preconditionsDigest !== constraints.approvedPreconditionsDigest) + throw new Error('conditional_action_preconditions_mismatch'); + const expected = { idempotencyKey: input.permit.idempotencyKey, actionDigest: input.permit.actionDigest, + inputDigest: actionInputDigest(input.input), preconditionsDigest }; + const receipt = await execute({ ...input, preconditions, preconditionsDigest }); + if (receipt.schemaVersion !== 1 || receipt.idempotencyKey !== expected.idempotencyKey || + receipt.actionDigest !== expected.actionDigest || receipt.inputDigest !== expected.inputDigest || + receipt.preconditionsDigest !== expected.preconditionsDigest || + !['succeeded','not_applied'].includes(receipt.outcome) || typeof receipt.proofRef !== 'string' || !receipt.proofRef.trim() || + !receipt.result || typeof receipt.result.ok !== 'boolean' || + receipt.result.ok !== (receipt.outcome === 'succeeded')) + throw new Error('conditional_action_receipt_mismatch'); + // Settlement/refund remains the existing admission reconciliation owner's job. + return structuredClone(receipt.result); + }, + }); +} diff --git a/packages/inference-control/src/tools.ts b/packages/inference-control/src/tools.ts index bfda2073..40b418f1 100644 --- a/packages/inference-control/src/tools.ts +++ b/packages/inference-control/src/tools.ts @@ -228,6 +228,64 @@ export function actionDigest( }); } +/** Host-supplied references to an already authorized assessment. */ +export interface CreateActionGrantInputV1 { + readonly grantId: string; + readonly scope: ActionScope; + readonly binding: ActionBinding; + readonly input: ControlJsonObject; + readonly assessmentRequestId: string; + readonly assessmentId: string; + readonly assessmentTargetDigest: string; + readonly idempotencyKey: string; + readonly issuedAtLogicalMs: number; + readonly expiresAtLogicalMs: number; +} + +/** + * Prepares an immutable grant using the existing V1 digest format. + * Does not authenticate, assess, persist or authorize execution. Trusted hosts + * must supply verified assessment references and issue through their repository. + */ +export function createActionGrantV1(options: CreateActionGrantInputV1): ActionGrant { + if (!isActionScopeV1(options.scope)) + throw new TypeError("Invalid Action Grant scope"); + const binding = options.binding; + if ( + binding.schemaVersion !== 1 || + ![binding.actionBindingId, binding.namespace, binding.toolId, + binding.operation, binding.dispatcherId, binding.contextResolverId] + .every(nonEmptyString) || + ![binding.actionBindingVersion, binding.dispatcherVersion, + binding.contextResolverVersion].every(positiveInteger) || + !isDigest(binding.handlerDigest) || + !["local_only", "downstream_atomic"].includes(binding.fencingMode) || + !nonEmptyString(options.assessmentRequestId) || + !nonEmptyString(options.assessmentId) || + !isDigest(options.assessmentTargetDigest) + ) throw new TypeError("Invalid Action Grant binding or assessment references"); + const input = freezeControlJsonObject(options.input, MAX_ACTION_INPUT_BYTES_V1); + const scope = freezeScope(options.scope); + const provisional: ActionGrant = { + schemaVersion: 1, grantId: options.grantId, stateGeneration: 1, + scope, scopeDigest: scopeDigest(scope), + namespace: binding.namespace, toolId: binding.toolId, + operation: binding.operation, actionBindingId: binding.actionBindingId, + actionBindingVersion: binding.actionBindingVersion, + handlerDigest: binding.handlerDigest, inputDigest: actionInputDigest(input), + actionDigest: "", assessmentRequestId: options.assessmentRequestId, + assessmentId: options.assessmentId, + assessmentTargetDigest: options.assessmentTargetDigest, + idempotencyKey: options.idempotencyKey, + issuedAtLogicalMs: options.issuedAtLogicalMs, + expiresAtLogicalMs: options.expiresAtLogicalMs, + singleUse: true, status: "issued", reservation: null, + }; + const grant = { ...provisional, actionDigest: actionDigest(provisional, binding) }; + assertIssuableGrant(grant); + return freezeGrant(grant); +} + function freezeGrant(grant: ActionGrant): ActionGrant { return Object.freeze({ ...grant, @@ -523,6 +581,30 @@ export async function issueActionGrantV1( return result.grant; } +/** + * Conservatively recovers a durable reservation after its worker stopped or was + * fenced. Never reissues or redispatches. The host verifier must establish that + * the original attempt cannot perform further effects before reconciliation. + */ +export async function recoverReservedActionGrantV1( + repository: ActionGrantRepository, + input: { readonly grantId: string; readonly reservationId: string; readonly dispatchAttemptId: string }, + verifyStoppedOrFenced: (grant: ActionGrant) => Promise, +): Promise { + const grant = await repository.loadGrant(input.grantId); + if (!grant) throw new Error("grant_missing"); + assertGrantSnapshot(grant, Number.MAX_SAFE_INTEGER); + if (grant.reservation?.reservationId !== input.reservationId || + grant.reservation.dispatchAttemptId !== input.dispatchAttemptId) + throw new Error("state_conflict"); + if (grant.status === "indeterminate") return grant; + if (grant.status !== "reserved" || !(await verifyStoppedOrFenced(freezeGrant(grant)))) + throw new Error("grant_recovery_unverified"); + const next = freezeGrant({ ...grant, stateGeneration: grant.stateGeneration + 1, status: "indeterminate" }); + if (!(await compareAndSwapGrantState(repository, grant, next))) throw new Error("state_conflict"); + return next; +} + /** Resolves an indeterminate grant only from an explicit authoritative proof. */ export async function reconcileActionGrantV1( repository: ActionGrantRepository, diff --git a/scripts/pack-consumers/action-control.mjs b/scripts/pack-consumers/action-control.mjs new file mode 100644 index 00000000..c78d81af --- /dev/null +++ b/scripts/pack-consumers/action-control.mjs @@ -0,0 +1,66 @@ +import { createConditionalActionDispatcherV1 } from '@agentplat/inference-control/action-effects'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { recoverReservedActionGrantV1, createActionGrantV1, scopeDigest, actionInputDigest, actionDigest, canonicalControlJson } from '@agentplat/inference-control/tools'; +import { createActionApprovalTargetV1, ActionApprovalServiceV1, InMemoryActionApprovalRepositoryV1, + createActionApprovalAssessmentResolverV1 } from '@agentplat/inference-control/action-approvals'; +import { InMemoryActionAdmissionStoreV1, ActionAdmissionServiceV1 } from '@agentplat/inference-control/action-admission'; +import { PostgresActionApprovalRepositoryV1, runActionApprovalMigrationsV1 } from '@agentplat/collective-control-postgres/action-approvals'; +import { PostgresActionAdmissionStoreV1, runActionAdmissionMigrationsV1 } from '@agentplat/collective-control-postgres/action-admission'; +const scope = { schemaVersion: 1, kind: 'standalone', tenantId: 'tenant:packed', runId: 'run:packed', agentId: 'agent:packed', + organizationId: null, workspaceId: null, policyId: 'policy:packed', policyVersion: 1 }; +const binding = { schemaVersion: 1, actionBindingId: 'binding:packed', actionBindingVersion: 1, namespace: 'packed', + toolId: 'tool:packed', operation: 'write', dispatcherId: 'dispatcher:packed', dispatcherVersion: 1, + contextResolverId: 'context:packed', contextResolverVersion: 1, fencingMode: 'local_only', handlerDigest: `sha256:${'1'.repeat(64)}` }; +const target = createActionApprovalTargetV1({ scope, binding, input: {}, preconditions: { version: 1 }, authority: { epoch: 1 }, policy: { version: 1 } }); +const approvals = new InMemoryActionApprovalRepositoryV1(); +const access = { resolve: async c => c, canRequest: async () => true, canDecide: async () => true }; +const service = new ActionApprovalServiceV1(approvals, access); +const requested = await service.request({ tenantId: scope.tenantId, actorId: scope.agentId, kind: 'agent' }, { + approvalId: 'approval:packed', target, createdAtMs: 1, expiresAtMs: 1000 }); +await service.decide({ tenantId: scope.tenantId, actorId: 'person:packed', kind: 'person' }, { + tenantId: scope.tenantId, approvalId: requested.approvalId, targetDigest: requested.targetDigest, decision: 'approved', nowMs: 2 }); +const grant = createActionGrantV1({ grantId: 'grant:packed', scope, binding, input: {}, + assessmentRequestId: 'assessment-request:packed', assessmentId: 'assessment:packed', + assessmentTargetDigest: requested.targetDigest, idempotencyKey: 'effect:packed', issuedAtLogicalMs: 3, expiresAtLogicalMs: 100 }); +const manual = { schemaVersion: 1, grantId: grant.grantId, stateGeneration: 1, scope, scopeDigest: scopeDigest(scope), + namespace: binding.namespace, toolId: binding.toolId, operation: binding.operation, + actionBindingId: binding.actionBindingId, actionBindingVersion: binding.actionBindingVersion, handlerDigest: binding.handlerDigest, + inputDigest: actionInputDigest({}), actionDigest: '', assessmentRequestId: grant.assessmentRequestId, + assessmentId: grant.assessmentId, assessmentTargetDigest: grant.assessmentTargetDigest, idempotencyKey: grant.idempotencyKey, + issuedAtLogicalMs: 3, expiresAtLogicalMs: 100, singleUse: true, status: 'issued', reservation: null }; +manual.actionDigest = actionDigest(manual, binding); +assert.equal(canonicalControlJson(grant), canonicalControlJson(manual)); +const guard = createActionApprovalAssessmentResolverV1({ repository: approvals, + base: { assessorId: 'packed', assessorVersion: 1, consumeCurrent: async () => true }, + approvalId: async () => requested.approvalId, currentTarget: async () => target }); +assert.equal(await guard.consumeCurrent(grant, 3), true); +const admission = new ActionAdmissionServiceV1(new InMemoryActionAdmissionStoreV1(approvals)); +const fences = ['agent','connector','organization'].map(kind => ({ kind, id: kind, active: true, epoch: 1 })); +for (const fence of fences) await admission.configureFence(scope.tenantId, fence); +await admission.configureBudget(scope.tenantId, { accountId: 'shared:period:0', unit: 'operation', maximumUnits: 1, + periodStartMs: 0, periodEndMs: 100, revision: 1 }); +const request = { tenantId: scope.tenantId, effectId: 'effect:packed', gatewayId: 'gateway:packed', scopeDigest: grant.scopeDigest, + grantId: grant.grantId, dispatchAttemptId: 'attempt:packed', idempotencyKey: grant.idempotencyKey, + actionDigest: grant.actionDigest, inputDigest: grant.inputDigest, fences, + charges: [{ accountId: 'shared:period:0', accountRevision: 1, unit: 'operation', units: 1 }], nowMs: 3, + approval: { approvalId: requested.approvalId, targetDigest: requested.targetDigest } }; +assert.equal((await admission.reserve(request)).status, 'admitted'); +assert.equal((await admission.reserve(request)).status, 'replayed'); +await admission.configureFence(scope.tenantId, { ...fences[0], epoch: 2, active: false }); +await assert.rejects(admission.reserve({ ...request, effectId: 'another', grantId: 'another', dispatchAttemptId: 'another', idempotencyKey: 'another', approval: null })); +// Construction/import must not perform database I/O. +const pool = { query() { throw Error('unexpected_database_io'); } }; +void new PostgresActionApprovalRepositoryV1(pool, { tenantId: scope.tenantId }); +void new PostgresActionAdmissionStoreV1(pool, { tenantId: scope.tenantId }); +assert.equal(typeof runActionApprovalMigrationsV1, 'function'); +assert.equal(typeof runActionAdmissionMigrationsV1, 'function'); +const entry = import.meta.resolve('@agentplat/collective-control-postgres/action-admission'); +for (const name of ['001_action_admission', '001_action_approvals']) + for (const direction of ['up','down']) + assert.ok((await readFile(new URL(`../migrations/${name}.${direction}.sql`, entry), 'utf8')).length > 0); +console.log('Verified standalone action-control public tarballs, legacy grant bytes, approvals, admission and migration files.'); + +assert.equal(typeof createConditionalActionDispatcherV1, 'function'); + +assert.equal(typeof recoverReservedActionGrantV1, 'function'); diff --git a/scripts/pack-consumers/inference-control-alpha3.mjs b/scripts/pack-consumers/inference-control-alpha3.mjs index 8af354ff..38dc6aef 100644 --- a/scripts/pack-consumers/inference-control-alpha3.mjs +++ b/scripts/pack-consumers/inference-control-alpha3.mjs @@ -7,6 +7,7 @@ import { import { ControlledModelExecutorV1 } from '@agentplat/inference-control/model'; import { createControlledAgentSseValidatorV1 } from '@agentplat/inference-control/runtime'; import { + createActionGrantV1, ActionGateway, LocalGrantLedger, actionDigest, @@ -361,6 +362,17 @@ const grant = { ...provisionalGrant, actionDigest: actionDigest(provisionalGrant, binding), }; +const preparedGrant = createActionGrantV1({ + grantId: provisionalGrant.grantId, scope, binding, input: {}, + assessmentRequestId: provisionalGrant.assessmentRequestId, + assessmentId: provisionalGrant.assessmentId, + assessmentTargetDigest: provisionalGrant.assessmentTargetDigest, + idempotencyKey: provisionalGrant.idempotencyKey, + issuedAtLogicalMs: provisionalGrant.issuedAtLogicalMs, + expiresAtLogicalMs: provisionalGrant.expiresAtLogicalMs, +}); +if (canonicalControlJson(preparedGrant) !== canonicalControlJson(grant)) + throw new Error('prepared grant differs from legacy V1 bytes'); const grantLedger = new LocalGrantLedger('gateway:packed'); grantLedger.issue(grant); let actionDispatches = 0; diff --git a/scripts/verify-action-control.mjs b/scripts/verify-action-control.mjs new file mode 100644 index 00000000..e81071a5 --- /dev/null +++ b/scripts/verify-action-control.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; + +assert.equal(process.env.AGENTPLAT_POSTGRES_TEST, '1', + 'Action-control qualification requires AGENTPLAT_POSTGRES_TEST=1 and a disposable local PostgreSQL database'); +const tests = [ + 'tests/inference-control-action-control-postgres.test.mjs', + 'tests/inference-control-action-effects.test.mjs', + 'tests/inference-control-action-admission.test.mjs', + 'tests/inference-control-action-admission-postgres.test.mjs', + 'tests/inference-control-action-approvals.test.mjs', + 'tests/inference-control-action-approvals-postgres.test.mjs', + 'tests/inference-control-grant-builder.test.mjs', + 'tests/inference-control-gateways.test.mjs', + 'tests/inference-control-action-grant-repository.test.mjs', +]; +execFileSync('pnpm', ['run','build'], { stdio: 'inherit' }); +execFileSync('pnpm', ['run','type-check:public'], { stdio: 'inherit' }); +const tap = execFileSync(process.execPath, ['--test','--test-reporter=tap', ...tests], { encoding: 'utf8' }); +process.stdout.write(tap); +assert.match(tap, /# fail 0\b/); +assert.match(tap, /# skipped 0\b/); +assert.match(tap, /# todo 0\b/); +assert.match(tap, /# cancelled 0\b/); +execFileSync('pnpm', ['run','verify:action-control-consumer'], { stdio: 'inherit' }); +console.log('Standalone action-control qualification passed: real PostgreSQL, public types and independent tarballs; no deployment or publication.'); diff --git a/scripts/verify-public-consumer.mjs b/scripts/verify-public-consumer.mjs index ab28c5cb..1a0eff9f 100644 --- a/scripts/verify-public-consumer.mjs +++ b/scripts/verify-public-consumer.mjs @@ -8,8 +8,10 @@ import { discoverWorkspacePackageManifests } from './public-package-catalog.mjs' const root = process.cwd(); const purposeGovernance = process.argv.includes('--purpose-governance'); +const actionControl = process.argv.includes('--action-control'); const optionalJev = process.argv.includes('--optional-jev'); const targets = Object.freeze([ + ...(actionControl ? ['@agentplat/inference-control', '@agentplat/collective-control-postgres'] : []), ...(optionalJev ? ['@agentplat/assessor-typesafe'] : []), ...(purposeGovernance ? [ '@agentplat/rooms', '@agentplat/rooms-api', @@ -29,6 +31,7 @@ const required = collectInternalClosure(targets, recordsByName); const registryRelease = process.env.AGENTPLAT_PUBLIC_CONSUMER_SOURCE === 'registry'; assert.ok(!purposeGovernance || !registryRelease, 'Purpose governance currently verifies local tarballs, not an unpublished registry surface'); +assert.ok(!actionControl || !registryRelease, 'Action-control additions currently require local prepared tarballs'); const registryVersion = JSON.parse( await readFile(path.join(root, 'package.json'), 'utf8'), ).version; @@ -139,6 +142,18 @@ try { }, }, ); + if (actionControl) { + await writeFile(path.join(consumerRoot, 'action-control.mjs'), + await readFile(path.join(root, 'scripts/pack-consumers/action-control.mjs'), 'utf8')); + execFileSync(process.execPath, ['action-control.mjs'], { cwd: consumerRoot, stdio: 'inherit' }); + for (const name of ['action-approvals', 'action-admission', 'action-effects']) { + await writeFile(path.join(consumerRoot, `${name}.mts`), + await readFile(path.join(root, `tests/inference-control-${name}.test.mts`), 'utf8')); + } + const config = JSON.parse(await readFile(path.join(consumerRoot, 'tsconfig.json'), 'utf8')); + config.include.push('*.mts'); + await writeFile(path.join(consumerRoot, 'tsconfig.json'), JSON.stringify(config)); + } if(optionalJev){ await writeFile(path.join(consumerRoot,'optional-jev.mjs'),await readFile(path.join(root,'scripts/pack-consumers/optional-jev.mjs'),'utf8')); execFileSync(process.execPath,['optional-jev.mjs'],{cwd:consumerRoot,stdio:'inherit'}); diff --git a/tests/helpers/action-admission-scenarios.mjs b/tests/helpers/action-admission-scenarios.mjs new file mode 100644 index 00000000..91272503 --- /dev/null +++ b/tests/helpers/action-admission-scenarios.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { ActionAdmissionServiceV1 } from '../../packages/inference-control/dist/action-admission.js'; +export const tenantId = 'tenant:admission'; +export const fences = ['agent','connector','organization'].map(kind => ({ kind, id: `${kind}:1`, epoch: 1, active: true })); +export const accounts = [ + { accountId: 'org:operations:period:0', unit: 'operation', periodStartMs: 0, periodEndMs: 100, maximumUnits: 3, revision: 1 }, + { accountId: 'org:spend:period:0', unit: 'cent', periodStartMs: 0, periodEndMs: 100, maximumUnits: 30, revision: 1 }, +]; +export function request(effectId, changes = {}) { + return { tenantId, effectId, gatewayId: 'gateway:1', scopeDigest: `sha256:${'4'.repeat(64)}`, grantId: `grant:${effectId}`, dispatchAttemptId: `attempt:${effectId}`, + idempotencyKey: `key:${effectId}`, actionDigest: `sha256:${'1'.repeat(64)}`, inputDigest: `sha256:${'2'.repeat(64)}`, + fences, charges: accounts.map(x => ({ accountId: x.accountId, accountRevision: 1, unit: x.unit, units: x.unit === 'cent' ? 10 : 1 })), + nowMs: 1, approval: null, ...changes }; +} +export async function configure(store) { + const service = new ActionAdmissionServiceV1(store); + for (const fence of fences) await service.configureFence(tenantId, fence); + for (const account of accounts) await service.configureBudget(tenantId, account); + return service; +} +export async function snapshot(store) { + return store.transaction(tenantId, state => ({ state, result: state })); +} +export async function sharedAdmissionScenarios(store, second = store) { + const service = await configure(store), other = new ActionAdmissionServiceV1(second); + const results = await Promise.allSettled(Array.from({ length: 10 }, (_, i) => + (i % 2 ? service : other).reserve(request(`effect:${i}`)))); + assert.equal(results.filter(x => x.status === 'fulfilled').length, 3); + const winner = results.find(x => x.status === 'fulfilled').value.receipt; + let state = await snapshot(store); + assert.deepEqual(accounts.map(a => state.budgets.find(x => x.account.accountId === a.accountId).usedUnits), [3, 30]); + assert.equal((await service.reserve({ ...winner.request, nowMs: 2 })).status, 'replayed'); + await assert.rejects(service.reserve({ ...winner.request, inputDigest: `sha256:${'3'.repeat(64)}`, nowMs: 2 })); + await assert.rejects(service.reconcile(tenantId, winner.request.effectId, async () => null)); + const proof = { requestDigest: winner.requestDigest, outcome: 'not_applied', proofRef: 'terminal:external:receipt' }; + await Promise.all([service.reconcile(tenantId, winner.request.effectId, async () => proof), + other.reconcile(tenantId, winner.request.effectId, async () => proof)]); + state = await snapshot(store); + assert.deepEqual(accounts.map(a => state.budgets.find(x => x.account.accountId === a.accountId).usedUnits), [2, 20]); + await assert.rejects(service.reconcile(tenantId, winner.request.effectId, + async () => ({ ...proof, outcome: 'succeeded' }))); + // Budget holds remain spent across policy revision and revocation. + await service.configureBudget(tenantId, { ...accounts[0], revision: 2, maximumUnits: 2 }); + await assert.rejects(other.reserve(request('after-cap-change', { nowMs: 3 }))); + await service.configureFence(tenantId, { ...fences[1], epoch: 2, active: false }); + await assert.rejects(other.reserve(request('suspended', { nowMs: 3 }))); + state = await snapshot(store); + assert.deepEqual(accounts.map(a => state.budgets.find(x => x.account.accountId === a.accountId).usedUnits), [2, 20]); + return winner; +} diff --git a/tests/helpers/action-approval-fixtures.mjs b/tests/helpers/action-approval-fixtures.mjs new file mode 100644 index 00000000..bfbd5fd3 --- /dev/null +++ b/tests/helpers/action-approval-fixtures.mjs @@ -0,0 +1,40 @@ +import { createActionApprovalTargetV1, ActionApprovalServiceV1, + InMemoryActionApprovalRepositoryV1, createActionApprovalAssessmentResolverV1, +} from '../../packages/inference-control/dist/action-approvals.js'; +import { createActionGrantV1 } from '../../packages/inference-control/dist/tools.js'; + +export const scope = { schemaVersion: 1, kind: 'standalone', tenantId: 'tenant:1', + runId: 'run:1', agentId: 'agent:1', organizationId: null, workspaceId: null, + policyId: 'policy:1', policyVersion: 1 }; +export const binding = { schemaVersion: 1, actionBindingId: 'binding:1', actionBindingVersion: 1, + namespace: 'test', toolId: 'tool:1', operation: 'write', dispatcherId: 'dispatcher:1', + dispatcherVersion: 1, contextResolverId: 'context:1', contextResolverVersion: 1, + fencingMode: 'local_only', handlerDigest: `sha256:${'1'.repeat(64)}` }; +export function target(overrides = {}) { + return createActionApprovalTargetV1({ scope, binding, input: { value: 2 }, + preconditions: { version: 1 }, authority: { agent: 1, connector: 1, organization: 1 }, + policy: { version: 1 }, ...overrides }); +} +export const requester = { tenantId: 'tenant:1', actorId: 'agent:1', kind: 'agent' }; +export const approver = { tenantId: 'tenant:1', actorId: 'person:1', kind: 'person' }; +export const access = { async resolve(c) { return c; }, async canRequest() { return true; }, + async canDecide() { return true; } }; +export async function approved(repository = new InMemoryActionApprovalRepositoryV1()) { + const service = new ActionApprovalServiceV1(repository, access); + const request = await service.request(requester, { approvalId: 'approval:1', target: target(), + createdAtMs: 1, expiresAtMs: 1000 }); + const record = await service.decide(approver, { tenantId: 'tenant:1', approvalId: request.approvalId, + targetDigest: request.targetDigest, decision: 'approved', nowMs: 2 }); + return { repository, service, record }; +} +export function grant(record, id = 'grant:1') { + return createActionGrantV1({ grantId: id, scope, binding, input: { value: 2 }, + assessmentRequestId: 'assessment-request:1', assessmentId: 'assessment:1', + assessmentTargetDigest: record.targetDigest, idempotencyKey: `effect:${id}`, + issuedAtLogicalMs: 3, expiresAtLogicalMs: 100 }); +} +export function resolver(repository, currentTarget = async () => target(), base = true) { + return createActionApprovalAssessmentResolverV1({ repository, + base: { assessorId: 'assessor:1', assessorVersion: 1, async consumeCurrent() { return base; } }, + approvalId: async () => 'approval:1', currentTarget }); +} diff --git a/tests/helpers/action-control-crash-worker.mjs b/tests/helpers/action-control-crash-worker.mjs new file mode 100644 index 00000000..dd4961e5 --- /dev/null +++ b/tests/helpers/action-control-crash-worker.mjs @@ -0,0 +1,23 @@ +import { Pool } from 'pg'; +import { PostgresActionGrantRepositoryV1 } from '../../packages/collective-control-postgres/dist/index.js'; +import { PostgresActionApprovalRepositoryV1 } from '../../packages/collective-control-postgres/dist/action-approvals.js'; +import { PostgresActionAdmissionStoreV1 } from '../../packages/collective-control-postgres/dist/action-admission.js'; +import { ActionAdmissionServiceV1 } from '../../packages/inference-control/dist/action-admission.js'; +import { scope } from './action-approval-fixtures.mjs'; +import { gateway, invoke } from './action-control-fixtures.mjs'; +const schema = process.argv[2]; +if (!/^control_test_[a-f0-9]+$/.test(schema)) throw Error('invalid_test_schema'); +const pool = new Pool({ database: 'postgres', host: '127.0.0.1', connectionString: process.env.DATABASE_URL }); +const grants = new PostgresActionGrantRepositoryV1(pool, { schema, tenantId: scope.tenantId, gatewayId: 'gateway:persistent' }); +const approvals = new PostgresActionApprovalRepositoryV1(pool, { schema, tenantId: scope.tenantId }); +const store = new PostgresActionAdmissionStoreV1(pool, { schema, tenantId: scope.tenantId }); +const record = await approvals.load(scope.tenantId, 'approval:1'); +const profile = { grants, approvals, store, record, admission: new ActionAdmissionServiceV1(store) }; +await invoke(gateway(profile, async () => { + const state = await store.transaction(scope.tenantId, state => ({ state, result: state })); + const receipt = state.effects[0]; + await pool.query(`INSERT INTO "${schema}".external_receipts VALUES ($1,$2)`, [receipt.request.effectId, receipt.requestDigest]); + // Actual process termination before the gateway can settle its reserved grant. + process.exit(70); +})); +throw Error('worker_should_have_terminated'); diff --git a/tests/helpers/action-control-fixtures.mjs b/tests/helpers/action-control-fixtures.mjs new file mode 100644 index 00000000..fbc879be --- /dev/null +++ b/tests/helpers/action-control-fixtures.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { Pool } from 'pg'; +import { runMigrations, PostgresActionGrantRepositoryV1 } from '../../packages/collective-control-postgres/dist/index.js'; +import { runActionApprovalMigrationsV1, PostgresActionApprovalRepositoryV1 } + from '../../packages/collective-control-postgres/dist/action-approvals.js'; +import { runActionAdmissionMigrationsV1, PostgresActionAdmissionStoreV1 } + from '../../packages/collective-control-postgres/dist/action-admission.js'; +import { ActionAdmissionServiceV1, createActionAdmissionDispatcherV1 } + from '../../packages/inference-control/dist/action-admission.js'; +import { ActionGateway, actionInputDigest, scopeDigest, issueActionGrantV1, reconcileActionGrantV1 } + from '../../packages/inference-control/dist/tools.js'; +import { approved, grant as prepareGrant, resolver, scope, binding, approver } + from './action-approval-fixtures.mjs'; +import { request, fences, accounts } from './action-admission-scenarios.mjs'; + +export async function setup(schema, pool) { + await runMigrations(pool, { schema, createSchema: true }); + const grants = new PostgresActionGrantRepositoryV1(pool, { schema, tenantId: scope.tenantId, gatewayId: 'gateway:persistent' }); + // Existing issued grant must survive installing the optional migrations. + const legacy = prepareGrant({ targetDigest: `sha256:${'2'.repeat(64)}` }, 'grant:legacy'); + await issueActionGrantV1(grants, legacy); + const before = await grants.loadGrant(legacy.grantId); + await runActionApprovalMigrationsV1(pool, { schema }); + await runActionAdmissionMigrationsV1(pool, { schema }); + assert.deepEqual(await grants.loadGrant(legacy.grantId), before); + const approvals = new PostgresActionApprovalRepositoryV1(pool, { schema, tenantId: scope.tenantId }); + const { record, service: approvalService } = await approved(approvals); + const store = new PostgresActionAdmissionStoreV1(pool, { schema, tenantId: scope.tenantId }); + const admission = new ActionAdmissionServiceV1(store); + for (const fence of fences) await admission.configureFence(scope.tenantId, fence); + for (const account of accounts) await admission.configureBudget(scope.tenantId, account); + const grant = prepareGrant(record); + await issueActionGrantV1(grants, grant); + return { grants, approvals, approvalService, admission, store, record, grant }; +} +export function gateway(profile, downstream, onQuote = async () => {}) { + const dispatcher = createActionAdmissionDispatcherV1({ service: profile.admission, + downstream: { dispatcherId: binding.dispatcherId, dispatcherVersion: 1, fencingMode: 'local_only', dispatch: downstream }, + quote: async ({ permit, input }) => { + await onQuote(); + return { ...request('effect:persistent'), tenantId: scope.tenantId, + gatewayId: permit.gatewayId, scopeDigest: permit.scopeDigest, grantId: permit.grantId, + dispatchAttemptId: permit.dispatchAttemptId, idempotencyKey: permit.idempotencyKey, + actionDigest: permit.actionDigest, inputDigest: actionInputDigest(input), nowMs: 3, + approval: { approvalId: profile.record.approvalId, targetDigest: profile.record.targetDigest } }; + }, + }); + return new ActionGateway(profile.grants, binding, dispatcher, + { contextResolverId: binding.contextResolverId, contextResolverVersion: 1, async resolve() { + return { tenant: { tenantId: scope.tenantId }, toolId: binding.toolId, runId: scope.runId }; } }, + { resolverId: 'authority:persistent', resolverVersion: 1, async resolve(s, actionDigest) { + return { schemaVersion: 1, resolverId: 'authority:persistent', resolverVersion: 1, status: 'current', + scope: s, scopeDigest: scopeDigest(s), actionDigest, authorityGeneration: null, fencingToken: null }; } }, + resolver(profile.approvals)); +} +export async function invoke(gateway) { return gateway.invoke({ schemaVersion: 1, grantId: 'grant:1', input: { value: 2 }, logicalTimeMs: 3 }); } diff --git a/tests/inference-control-action-admission-postgres.test.mjs b/tests/inference-control-action-admission-postgres.test.mjs new file mode 100644 index 00000000..9c194b46 --- /dev/null +++ b/tests/inference-control-action-admission-postgres.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { randomUUID } from 'node:crypto'; +import { Pool } from 'pg'; +import { PostgresActionAdmissionStoreV1, runActionAdmissionMigrationsV1 } + from '../packages/collective-control-postgres/dist/action-admission.js'; +import { sharedAdmissionScenarios, snapshot, tenantId, request } + from './helpers/action-admission-scenarios.mjs'; +import { ActionAdmissionServiceV1 } from '../packages/inference-control/dist/action-admission.js'; + +test('PostgreSQL atomic admission persists fences, budgets and receipts across pool reopen', + { skip: process.env.AGENTPLAT_POSTGRES_TEST !== '1' }, async () => { + const schema = `admission_test_${randomUUID().replaceAll('-', '')}`; + let pool = new Pool({ database: 'postgres', host: '127.0.0.1', connectionString: process.env.DATABASE_URL }); + try { + await runActionAdmissionMigrationsV1(pool, { schema, createSchema: true }); + const store = new PostgresActionAdmissionStoreV1(pool, { schema, tenantId }); + const second = new PostgresActionAdmissionStoreV1(pool, { schema, tenantId }); + await sharedAdmissionScenarios(store, second); + const before = await snapshot(store); + await pool.end(); + pool = new Pool({ database: 'postgres', host: '127.0.0.1', connectionString: process.env.DATABASE_URL }); + const reopened = new PostgresActionAdmissionStoreV1(pool, { schema, tenantId }); + assert.deepEqual(await snapshot(reopened), before); + await assert.rejects(new ActionAdmissionServiceV1(reopened).reserve(request('after-restart', { nowMs: 4 }))); + await assert.rejects(reopened.transaction('another-tenant', state => ({ state, result: null }))); + await runActionAdmissionMigrationsV1(pool, { schema }); + } finally { await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); await pool.end(); } +}); diff --git a/tests/inference-control-action-admission.test.mjs b/tests/inference-control-action-admission.test.mjs new file mode 100644 index 00000000..10258952 --- /dev/null +++ b/tests/inference-control-action-admission.test.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ActionAdmissionServiceV1, InMemoryActionAdmissionStoreV1, + createActionAdmissionDispatcherV1, actionAdmissionRequestDigestV1 } + from '../packages/inference-control/dist/action-admission.js'; +import { request, configure, snapshot, sharedAdmissionScenarios, tenantId, fences, accounts } + from './helpers/action-admission-scenarios.mjs'; + +test('shared multi-resource limits survive concurrency, replay, refund and revocation', async () => { + await sharedAdmissionScenarios(new InMemoryActionAdmissionStoreV1()); +}); +test('all charges reserve or none; invalid periods, units and account revisions deny', async () => { + const store = new InMemoryActionAdmissionStoreV1(), service = await configure(store); + const initial = await snapshot(store); + for (const patch of [ + { charges: [{ ...request('x').charges[0], units: 1 }, { ...request('x').charges[1], units: 31 }] }, + { charges: [{ ...request('x').charges[0], unit: 'other' }] }, + { charges: [{ ...request('x').charges[0], accountRevision: 2 }] }, + { charges: [{ ...request('x').charges[0], units: -1 }] }, + { charges: [request('x').charges[0], request('x').charges[0]] }, + { nowMs: 100 }, { fences: fences.slice(1) }, + ]) await assert.rejects(service.reserve(request('invalid', patch))); + assert.deepEqual(await snapshot(store), initial); +}); +test('revocation for every scope is ordered with admission and cannot revive old bindings', async () => { + for (const fence of fences) { + const store = new InMemoryActionAdmissionStoreV1(), service = await configure(store); + await service.configureFence(tenantId, { ...fence, epoch: 2, active: false }); + await assert.rejects(service.reserve(request('old'))); + await service.configureFence(tenantId, { ...fence, epoch: 3, active: true }); + await assert.rejects(service.reserve(request('old'))); + assert.equal((await service.reserve(request('fresh', { + fences: fences.map(x => x.kind === fence.kind ? { ...x, epoch: 3 } : x), + }))).status, 'admitted'); + } +}); +test('period renewal preserves accounting and logical time never rolls back', async () => { + const store = new InMemoryActionAdmissionStoreV1(), service = await configure(store); + await service.reserve(request('first', { nowMs: 50 })); + await assert.rejects(service.reserve(request('rollback', { nowMs: 49 }))); + await assert.rejects(service.configureBudget(tenantId, { ...accounts[0], revision: 2, periodEndMs: 200 })); + const next = { ...accounts[0], accountId: 'org:operations:period:100', periodStartMs: 100, periodEndMs: 200 }; + await service.configureBudget(tenantId, next); + await service.reserve(request('new-period', { nowMs: 100, charges: [{ accountId: next.accountId, accountRevision: 1, units: 1, unit: 'operation' }] })); + const state = await snapshot(store); + assert.equal(state.budgets.find(x => x.account.accountId === accounts[0].accountId).usedUnits, 1); +}); +test('dispatcher records before effect and never redispatches an uncertain admission', async () => { + const store = new InMemoryActionAdmissionStoreV1(), service = await configure(store); + let calls = 0; + const q = request('wrapped'); + const downstream = { dispatcherId: 'dispatcher', dispatcherVersion: 1, fencingMode: 'local_only', + async dispatch() { calls++; throw Error('response_lost'); } }; + const input = { input: {}, context: { tenant: { tenantId } }, + permit: { gatewayId: q.gatewayId, scopeDigest: q.scopeDigest, grantId: q.grantId, dispatchAttemptId: q.dispatchAttemptId, idempotencyKey: q.idempotencyKey, actionDigest: q.actionDigest } }; + const { actionInputDigest } = await import('../packages/inference-control/dist/tools.js'); + const wrapper = createActionAdmissionDispatcherV1({ downstream, service, + quote: async () => ({ ...q, inputDigest: actionInputDigest({}) }) }); + await assert.rejects(wrapper.dispatch(input), /response_lost/); + await assert.rejects(wrapper.dispatch(input), /already_admitted/); + assert.equal(calls, 1); + assert.equal((await snapshot(store)).effects[0].status, 'indeterminate'); +}); + +test('one dispatch identity cannot evade duplicate admission by changing effectId', async () => { + const service = await configure(new InMemoryActionAdmissionStoreV1()); + const original = request('one'); + await service.reserve(original); + await assert.rejects(service.reserve({ ...original, effectId: 'renamed' }), /dispatch_identity_conflict/); +}); + +test('actual ActionGateway composes approval evidence and resource admission before dispatch', async () => { + const { approved, grant: prepareGrant, resolver, scope, binding } = await import('./helpers/action-approval-fixtures.mjs'); + const { ActionGateway, LocalGrantLedger, actionInputDigest, scopeDigest } = await import('../packages/inference-control/dist/tools.js'); + const { record, repository } = await approved(); + const g = prepareGrant(record); + const store = new InMemoryActionAdmissionStoreV1(repository); + const service = await configure(store); + // Fixtures use a different tenant. Configure its own independent accounts. + for (const fence of fences) await service.configureFence(scope.tenantId, fence); + for (const account of accounts) await service.configureBudget(scope.tenantId, account); + const ledger = new LocalGrantLedger('gateway:composed'); ledger.issue(g); + let effects = 0; + const dispatcher = createActionAdmissionDispatcherV1({ service, + downstream: { dispatcherId: binding.dispatcherId, dispatcherVersion: 1, fencingMode: 'local_only', + async dispatch() { effects++; return { ok: true, value: { written: true } }; } }, + quote: async ({ permit, input }) => ({ ...request('composed'), tenantId: scope.tenantId, + gatewayId: permit.gatewayId, scopeDigest: permit.scopeDigest, grantId: permit.grantId, + dispatchAttemptId: permit.dispatchAttemptId, idempotencyKey: permit.idempotencyKey, + actionDigest: permit.actionDigest, inputDigest: actionInputDigest(input), nowMs: 3, + approval: { approvalId: record.approvalId, targetDigest: record.targetDigest } }), + }); + const gateway = new ActionGateway(ledger, binding, dispatcher, + { contextResolverId: binding.contextResolverId, contextResolverVersion: 1, + async resolve() { return { tenant: { tenantId: scope.tenantId }, toolId: binding.toolId, runId: scope.runId }; } }, + { resolverId: 'authority', resolverVersion: 1, async resolve(s, digest) { + return { schemaVersion: 1, status: 'current', resolverId: 'authority', resolverVersion: 1, + scope: s, scopeDigest: scopeDigest(s), actionDigest: digest, authorityGeneration: null, fencingToken: null }; } }, + resolver(repository)); + assert.equal((await gateway.invoke({ schemaVersion: 1, grantId: g.grantId, input: { value: 2 }, logicalTimeMs: 3 })).ok, true); + assert.equal(effects, 1); + await assert.rejects(gateway.invoke({ schemaVersion: 1, grantId: g.grantId, input: { value: 2 }, logicalTimeMs: 4 })); + assert.equal(effects, 1); +}); diff --git a/tests/inference-control-action-admission.test.mts b/tests/inference-control-action-admission.test.mts new file mode 100644 index 00000000..5846f329 --- /dev/null +++ b/tests/inference-control-action-admission.test.mts @@ -0,0 +1,14 @@ +import { ActionAdmissionServiceV1, InMemoryActionAdmissionStoreV1, + createActionAdmissionDispatcherV1, type ActionAdmissionRequestV1 } + from '@agentplat/inference-control/action-admission'; +import type { ActionDispatcher } from '@agentplat/inference-control/tools'; +import { PostgresActionAdmissionStoreV1, runActionAdmissionMigrationsV1 } + from '@agentplat/collective-control-postgres/action-admission'; +declare const pool: ConstructorParameters[0]; +declare const downstream: ActionDispatcher; +declare const request: ActionAdmissionRequestV1; +const service = new ActionAdmissionServiceV1(new InMemoryActionAdmissionStoreV1()); +void service.reserve(request); +void createActionAdmissionDispatcherV1({ service, downstream, quote: async () => request }); +void new PostgresActionAdmissionStoreV1(pool, { tenantId: 'verified' }); +void runActionAdmissionMigrationsV1(pool); diff --git a/tests/inference-control-action-approvals-postgres.test.mjs b/tests/inference-control-action-approvals-postgres.test.mjs new file mode 100644 index 00000000..5e1c06a6 --- /dev/null +++ b/tests/inference-control-action-approvals-postgres.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { randomUUID } from 'node:crypto'; +import { Pool } from 'pg'; +import { PostgresActionApprovalRepositoryV1, runActionApprovalMigrationsV1 } + from '../packages/collective-control-postgres/dist/action-approvals.js'; +import { approved, grant, resolver, target, requester } from './helpers/action-approval-fixtures.mjs'; + +const integration = process.env.AGENTPLAT_POSTGRES_TEST === '1'; +test('PostgreSQL approvals survive reopen, enforce CAS and bind one effect across connections', + { skip: !integration }, async () => { + const schema = `approval_test_${randomUUID().replaceAll('-', '')}`; + let pool = new Pool({ database: 'postgres', host: '127.0.0.1', connectionString: process.env.DATABASE_URL }); + try { + await runActionApprovalMigrationsV1(pool, { schema, createSchema: true }); + const repository = new PostgresActionApprovalRepositoryV1(pool, { schema, tenantId: 'tenant:1' }); + const { record, service } = await approved(repository); + await assert.rejects(repository.load('tenant:2', 'approval:1')); + await pool.end(); + pool = new Pool({ database: 'postgres', host: '127.0.0.1', connectionString: process.env.DATABASE_URL }); + const reopened = new PostgresActionApprovalRepositoryV1(pool, { schema, tenantId: 'tenant:1' }); + assert.deepEqual(await reopened.load('tenant:1', record.approvalId), record); + const second = new PostgresActionApprovalRepositoryV1(pool, { schema, tenantId: 'tenant:1' }); + const results = await Promise.all([resolver(reopened).consumeCurrent(grant(record, 'one'), 3), + resolver(second).consumeCurrent(grant(record, 'two'), 3)]); + assert.equal(results.filter(Boolean).length, 1); + const old = await reopened.load('tenant:1', record.approvalId); + const invalidated = { ...old, revision: old.revision + 1, status: 'invalidated', observedAtMs: 4, closedAtMs: 4 }; + assert.equal(await reopened.compareAndSwap(old, invalidated), true); + assert.equal(await second.compareAndSwap(old, invalidated), false); + assert.equal(await resolver(reopened).consumeCurrent(grant(record, results[0] ? 'one' : 'two'), 5), false); + // Verify the opt-in migration is idempotent and does not create collective tables. + await runActionApprovalMigrationsV1(pool, { schema }); + const table = await pool.query('SELECT to_regclass($1) AS value', [`${schema}.collective_action_grants`]); + assert.equal(table.rows[0].value, null); + } finally { + await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); + await pool.end(); + } +}); diff --git a/tests/inference-control-action-approvals.test.mjs b/tests/inference-control-action-approvals.test.mjs new file mode 100644 index 00000000..30b8305a --- /dev/null +++ b/tests/inference-control-action-approvals.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createActionApprovalTargetV1, ActionApprovalServiceV1, + InMemoryActionApprovalRepositoryV1, createActionApprovalAssessmentResolverV1, +} from '../packages/inference-control/dist/action-approvals.js'; +import { createActionGrantV1 } from '../packages/inference-control/dist/tools.js'; + +import { scope, binding, target, requester, approver, access, approved, grant, resolver } from './helpers/action-approval-fixtures.mjs'; + +test('approval request is immutable, replayable and cannot cross tenants or identities', async () => { + const { repository, service, record } = await approved(); + const replay = await service.request(requester, { approvalId: 'approval:1', target: target(), + createdAtMs: 1, expiresAtMs: 1000 }); + assert.deepEqual(replay, record); + assert.equal(await repository.load('tenant:2', 'approval:1'), undefined); + await assert.rejects(service.request({ ...requester, tenantId: 'tenant:2' }, { + approvalId: 'approval:2', target: target(), createdAtMs: 1, expiresAtMs: 1000 })); + await assert.rejects(service.request(requester, { approvalId: 'approval:1', + target: target({ input: { value: 3 } }), createdAtMs: 1, expiresAtMs: 1000 })); +}); + +test('only authorized independent people decide exact pending requests', async () => { + for (const actor of [null, requester, { ...approver, actorId: requester.actorId }, + { ...approver, tenantId: 'tenant:2' }, { ...approver, kind: 'agent' }]) { + const service = new ActionApprovalServiceV1(new InMemoryActionApprovalRepositoryV1(), access); + const r = await service.request(requester, { approvalId: 'approval:1', target: target(), createdAtMs: 1, expiresAtMs: 10 }); + await assert.rejects(service.decide(actor, { tenantId: 'tenant:1', approvalId: r.approvalId, + targetDigest: r.targetDigest, decision: 'approved', nowMs: 2 })); + } + const { service, record } = await approved(); + await assert.rejects(service.decide(approver, { tenantId: 'tenant:1', approvalId: record.approvalId, + targetDigest: 'changed', decision: 'approved', nowMs: 3 })); +}); + +test('approval narrows base assessment, exact facts and expiry; uncertainty denies', async () => { + for (const change of [ { input: { value: 3 } }, { preconditions: { version: 2 } }, + { policy: { version: 2 } }, { authority: { agent: 2, connector: 1, organization: 1 } }, + { authority: { agent: 1, connector: 2, organization: 1 } }, + { authority: { agent: 1, connector: 1, organization: 2 } } ]) { + const { repository, record } = await approved(); + assert.equal(await resolver(repository, async () => target(change)).consumeCurrent(grant(record), 3), false); + assert.equal((await repository.load('tenant:1', record.approvalId)).status, 'invalidated'); + } + const { repository, record } = await approved(); + const g = grant(record); + assert.equal(await resolver(repository).consumeCurrent(g, 3), true); + assert.equal(await resolver(repository, undefined, false).consumeCurrent(g, 3), false); + assert.equal(await resolver(repository, async () => { throw Error('offline'); }).consumeCurrent(g, 3), false); + assert.equal(await resolver(repository).consumeCurrent({ ...g, inputDigest: `sha256:${'3'.repeat(64)}` }, 3), false); +}); + +test('expiry and invalidation are durable, preserve the decision and cannot be revived by clock rollback', async () => { + const { repository, record, service } = await approved(); + const g = grant(record), r = resolver(repository); + assert.equal(await r.consumeCurrent(g, 20), true); + assert.equal(await r.consumeCurrent(g, 19), false); + assert.equal(await r.consumeCurrent(g, 1000), false); + assert.equal(await r.consumeCurrent(g, 3), false); + const expired = await repository.load('tenant:1', record.approvalId); + assert.equal(expired.status, 'expired'); + assert.equal(expired.decidedBy, record.decidedBy); + assert.equal(expired.decidedAtMs, record.decidedAtMs); + const another = await approved(); + await another.service.invalidate(approver, { tenantId: 'tenant:1', approvalId: 'approval:1', + targetDigest: another.record.targetDigest, nowMs: 4 }); + assert.equal(await resolver(another.repository).consumeCurrent(grant(another.record), 4), false); +}); + +test('concurrent grants cannot reuse one approval for multiple effects', async () => { + const { repository, record } = await approved(); + const r = resolver(repository); + const outcomes = await Promise.all([r.consumeCurrent(grant(record, 'one'), 3), r.consumeCurrent(grant(record, 'two'), 3)]); + assert.equal(outcomes.filter(Boolean).length, 1); + assert.equal(await r.consumeCurrent(grant(record, outcomes[0] ? 'one' : 'two'), 3), true); +}); + +test('decision races have one durable winner and failed authorization never transitions', async () => { + const repository = new InMemoryActionApprovalRepositoryV1(); + const service = new ActionApprovalServiceV1(repository, access); + const r = await service.request(requester, { approvalId: 'approval:1', target: target(), createdAtMs: 1, expiresAtMs: 10 }); + const results = await Promise.allSettled(['approved','rejected'].map(decision => service.decide(approver, + { tenantId: 'tenant:1', approvalId: r.approvalId, targetDigest: r.targetDigest, decision, nowMs: 2 }))); + assert.equal(results.filter(x => x.status === 'fulfilled').length, 1); + const denied = new ActionApprovalServiceV1(repository, { ...access, async canDecide() { return false; } }); + await assert.rejects(denied.decide(approver, { tenantId: 'tenant:1', approvalId: r.approvalId, + targetDigest: r.targetDigest, decision: 'approved', nowMs: 3 })); +}); diff --git a/tests/inference-control-action-approvals.test.mts b/tests/inference-control-action-approvals.test.mts new file mode 100644 index 00000000..ce43ae80 --- /dev/null +++ b/tests/inference-control-action-approvals.test.mts @@ -0,0 +1,16 @@ +import { ActionApprovalServiceV1, InMemoryActionApprovalRepositoryV1, + createActionApprovalTargetV1, createActionApprovalAssessmentResolverV1, + type ActionApprovalAccessV1 } from '@agentplat/inference-control/action-approvals'; +import type { ActionScope, ActionBinding, ActionAssessmentResolver } from '@agentplat/inference-control/tools'; +declare const scope: ActionScope; +declare const binding: ActionBinding; +declare const access: ActionApprovalAccessV1<{ session: string }>; +declare const base: ActionAssessmentResolver; +const repository = new InMemoryActionApprovalRepositoryV1(); +const service = new ActionApprovalServiceV1(repository, access); +const target = createActionApprovalTargetV1({ scope, binding, input: {}, preconditions: {}, authority: {}, policy: {} }); +void service.request({ session: 'verified' }, { approvalId: 'one', target, createdAtMs: 1, expiresAtMs: 1000 }); +void createActionApprovalAssessmentResolverV1({ base, repository, + approvalId: async () => 'one', currentTarget: async () => target }); +// @ts-expect-error decisions require authenticated context +void service.decide('actor-id', { tenantId: 'one', approvalId: 'one', targetDigest: 'digest', decision: 'approved', nowMs: 2 }); diff --git a/tests/inference-control-action-control-postgres.test.mjs b/tests/inference-control-action-control-postgres.test.mjs new file mode 100644 index 00000000..3c33a482 --- /dev/null +++ b/tests/inference-control-action-control-postgres.test.mjs @@ -0,0 +1,191 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { randomUUID } from 'node:crypto'; +import { Pool } from 'pg'; +import { runMigrations, PostgresActionGrantRepositoryV1 } from '../packages/collective-control-postgres/dist/index.js'; +import { runActionApprovalMigrationsV1, PostgresActionApprovalRepositoryV1 } + from '../packages/collective-control-postgres/dist/action-approvals.js'; +import { runActionAdmissionMigrationsV1, PostgresActionAdmissionStoreV1 } + from '../packages/collective-control-postgres/dist/action-admission.js'; +import { ActionAdmissionServiceV1, createActionAdmissionDispatcherV1 } + from '../packages/inference-control/dist/action-admission.js'; +import { ActionGateway, actionInputDigest, scopeDigest, issueActionGrantV1, reconcileActionGrantV1 } + from '../packages/inference-control/dist/tools.js'; +import { approved, grant as prepareGrant, resolver, scope, binding, approver } + from './helpers/action-approval-fixtures.mjs'; +import { request, fences, accounts } from './helpers/action-admission-scenarios.mjs'; + +const enabled = process.env.AGENTPLAT_POSTGRES_TEST === '1'; +import { setup, gateway, invoke } from './helpers/action-control-fixtures.mjs'; + +test('persistent composed gateway reconciles committed effect after response loss without redispatch', { skip: !enabled }, async () => { + const schema = `control_test_${randomUUID().replaceAll('-', '')}`; + let pool = new Pool({ database: 'postgres', host: '127.0.0.1', connectionString: process.env.DATABASE_URL }); + try { + const profile = await setup(schema, pool); + await pool.query(`CREATE TABLE "${schema}".external_receipts (effect_id text PRIMARY KEY, request_digest text NOT NULL)`); + const g = gateway(profile, async () => { + const state = await profile.store.transaction(scope.tenantId, state => ({ state, result: state })); + const receipt = state.effects[0]; + await pool.query(`INSERT INTO "${schema}".external_receipts VALUES ($1,$2)`, [receipt.request.effectId, receipt.requestDigest]); + throw Error('external_response_lost'); + }); + await assert.rejects(invoke(g), /external_response_lost/); + const failed = await profile.grants.loadGrant('grant:1'); + assert.equal(failed.status, 'indeterminate'); + await pool.end(); + pool = new Pool({ database: 'postgres', host: '127.0.0.1', connectionString: process.env.DATABASE_URL }); + const store = new PostgresActionAdmissionStoreV1(pool, { schema, tenantId: scope.tenantId }); + const admission = new ActionAdmissionServiceV1(store); + const grants = new PostgresActionGrantRepositoryV1(pool, { schema, tenantId: scope.tenantId, gatewayId: 'gateway:persistent' }); + const reconciled = await admission.reconcile(scope.tenantId, 'effect:persistent', async receipt => { + const { rows } = await pool.query(`SELECT request_digest FROM "${schema}".external_receipts WHERE effect_id=$1`, [receipt.request.effectId]); + return rows[0]?.request_digest === receipt.requestDigest ? { + requestDigest: receipt.requestDigest, outcome: 'succeeded', proofRef: 'verified:external:receipt' } : null; + }); + assert.equal(reconciled.status, 'succeeded'); + await reconcileActionGrantV1(grants, { grantId: failed.grantId, + reservationId: failed.reservation.reservationId, dispatchAttemptId: failed.reservation.dispatchAttemptId, outcome: 'dispatched' }); + assert.equal((await grants.loadGrant(failed.grantId)).status, 'dispatched'); + const state = await store.transaction(scope.tenantId, state => ({ state, result: state })); + assert.deepEqual(state.budgets.map(x => x.usedUnits), [1, 10]); + assert.equal((await pool.query(`SELECT count(*)::int AS count FROM "${schema}".external_receipts`)).rows[0].count, 1); + const approvals = new PostgresActionApprovalRepositoryV1(pool, { schema, tenantId: scope.tenantId }); + let redispatches = 0; + await assert.rejects(invoke(gateway({ ...profile, grants, admission, approvals }, async () => { redispatches++; return { ok: true }; }))); + assert.equal(redispatches, 0); + } finally { await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); await pool.end(); } +}); + +test('approval invalidation while quoting wins before admission, with no budget charge or effect', { skip: !enabled }, async () => { + const schema = `control_test_${randomUUID().replaceAll('-', '')}`; + const pool = new Pool({ database: 'postgres', host: '127.0.0.1', connectionString: process.env.DATABASE_URL }); + try { + const profile = await setup(schema, pool); + let effects = 0; + const g = gateway(profile, async () => { effects++; return { ok: true }; }, async () => { + await profile.approvalService.invalidate(approver, { tenantId: scope.tenantId, + approvalId: profile.record.approvalId, targetDigest: profile.record.targetDigest, nowMs: 3 }); + }); + await assert.rejects(invoke(g), /action_approval_stale/); + const state = await profile.store.transaction(scope.tenantId, state => ({ state, result: state })); + assert.equal(state.effects.length, 0); + assert.deepEqual(state.budgets.map(x => x.usedUnits), [0, 0]); + assert.equal(effects, 0); + } finally { await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); await pool.end(); } +}); + +test('persisted approval expiry survives reopening and cannot be undone with an earlier time', { skip: !enabled }, async () => { + const schema = `control_test_${randomUUID().replaceAll('-', '')}`; + let pool = new Pool({ database: 'postgres', host: '127.0.0.1', connectionString: process.env.DATABASE_URL }); + try { + const profile = await setup(schema, pool); + assert.equal(await resolver(profile.approvals).consumeCurrent(profile.grant, 1000), false); + const expired = await profile.approvals.load(scope.tenantId, profile.record.approvalId); + assert.equal(expired.status, 'expired'); + assert.equal(expired.decidedAtMs, profile.record.decidedAtMs); + await pool.end(); + pool = new Pool({ database: 'postgres', host: '127.0.0.1', connectionString: process.env.DATABASE_URL }); + const reopened = new PostgresActionApprovalRepositoryV1(pool, { schema, tenantId: scope.tenantId }); + assert.equal(await resolver(reopened).consumeCurrent(profile.grant, 3), false); + assert.deepEqual(await reopened.load(scope.tenantId, profile.record.approvalId), expired); + } finally { await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); await pool.end(); } +}); + +test('conditional external write rejects a resource changed after approval and refunds only its terminal receipt', { skip: !enabled }, async () => { + const { createConditionalActionDispatcherV1 } = await import('../packages/inference-control/dist/action-effects.js'); + const { controlDigest } = await import('../packages/inference-control/dist/tools.js'); + const schema = `control_test_${randomUUID().replaceAll('-', '')}`; + const pool = new Pool({ database: 'postgres', host: '127.0.0.1', connectionString: process.env.DATABASE_URL }); + try { + const profile = await setup(schema, pool); + await pool.query(`CREATE TABLE "${schema}".resource (id text PRIMARY KEY, value integer NOT NULL, version integer NOT NULL)`); + await pool.query(`INSERT INTO "${schema}".resource VALUES ('one',1,1)`); + await pool.query(`CREATE TABLE "${schema}".conditional_receipts (idempotency_key text PRIMARY KEY, receipt jsonb NOT NULL)`); + const conditional = createConditionalActionDispatcherV1({ dispatcherId: binding.dispatcherId, dispatcherVersion: 1, + fencingMode: 'local_only', resolvePreconditions: async () => ({ preconditions: { version: 1 }, + approvedPreconditionsDigest: profile.record.target.preconditionsDigest }), + port: { + capabilities: { executorId: 'postgres:test:conditional', executorVersion: 1, + atomicPreconditions: true, idempotency: 'durable_adapter', receiptLookup: true }, + async execute(q) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + // The fixture's destination owns both resource and receipt atomically. + await client.query(`LOCK TABLE "${schema}".conditional_receipts IN EXCLUSIVE MODE`); + const old = await client.query(`SELECT receipt FROM "${schema}".conditional_receipts WHERE idempotency_key=$1`, [q.permit.idempotencyKey]); + if (old.rows[0]) { await client.query('COMMIT'); return old.rows[0].receipt; } + const write = await client.query(`UPDATE "${schema}".resource SET value=$1, version=version+1 WHERE id='one' AND version=$2`, + [q.input.value, q.preconditions.version]); + const outcome = write.rowCount === 1 ? 'succeeded' : 'not_applied'; + const receipt = { schemaVersion: 1, idempotencyKey: q.permit.idempotencyKey, + actionDigest: q.permit.actionDigest, inputDigest: actionInputDigest(q.input), + preconditionsDigest: q.preconditionsDigest, outcome, proofRef: `destination:${q.permit.idempotencyKey}`, + result: { ok: outcome === 'succeeded', errorMessage: outcome === 'not_applied' ? 'precondition_failed' : undefined } }; + await client.query(`INSERT INTO "${schema}".conditional_receipts VALUES ($1,$2::jsonb)`, [receipt.idempotencyKey, JSON.stringify(receipt)]); + await client.query('COMMIT'); return receipt; + } catch (error) { await client.query('ROLLBACK'); throw error; } + finally { client.release(); } + }, + }, + }); + // Change at the destination after assessment, before the conditional effect. + const g = gateway(profile, conditional.dispatch.bind(conditional), async () => { + await pool.query(`UPDATE "${schema}".resource SET version=2 WHERE id='one'`); + }); + const result = await invoke(g); + assert.equal(result.ok, false); + assert.equal(result.errorMessage, 'precondition_failed'); + assert.equal((await pool.query(`SELECT value FROM "${schema}".resource WHERE id='one'`)).rows[0].value, 1); + let state = await profile.store.transaction(scope.tenantId, state => ({ state, result: state })); + assert.equal(state.effects[0].status, 'indeterminate'); + assert.deepEqual(state.budgets.map(x => x.usedUnits), [1, 10]); + await profile.admission.reconcile(scope.tenantId, 'effect:persistent', async effect => { + const { rows } = await pool.query(`SELECT receipt FROM "${schema}".conditional_receipts WHERE idempotency_key=$1`, [effect.request.idempotencyKey]); + const receipt = rows[0]?.receipt; + return receipt?.outcome === 'not_applied' && receipt.actionDigest === effect.request.actionDigest && + receipt.inputDigest === effect.request.inputDigest && receipt.preconditionsDigest === profile.record.target.preconditionsDigest ? { + requestDigest: effect.requestDigest, outcome: 'not_applied', proofRef: receipt.proofRef } : null; + }); + state = await profile.store.transaction(scope.tenantId, state => ({ state, result: state })); + assert.deepEqual(state.budgets.map(x => x.usedUnits), [0, 0]); + assert.equal(state.effects[0].status, 'not_applied'); + } finally { await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); await pool.end(); } +}); + +test('hard worker termination retains the reserved grant and requires a verified recovery fence', { skip: !enabled }, async () => { + const { spawn } = await import('node:child_process'); + const { fileURLToPath } = await import('node:url'); + const { recoverReservedActionGrantV1 } = await import('../packages/inference-control/dist/tools.js'); + const schema = `control_test_${randomUUID().replaceAll('-', '')}`; + const pool = new Pool({ database: 'postgres', host: '127.0.0.1', connectionString: process.env.DATABASE_URL }); + try { + const profile = await setup(schema, pool); + await pool.query(`CREATE TABLE "${schema}".external_receipts (effect_id text PRIMARY KEY, request_digest text NOT NULL)`); + const child = spawn(process.execPath, [fileURLToPath(new URL('./helpers/action-control-crash-worker.mjs', import.meta.url)), schema], + { stdio: ['ignore', 'pipe', 'pipe'] }); + let diagnostic = ''; + child.stderr.on('data', chunk => diagnostic += chunk.toString()); + const exitCode = await new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', resolve); }); + assert.equal(exitCode, 70, diagnostic); + const stopped = await profile.grants.loadGrant('grant:1'); + assert.equal(stopped.status, 'reserved'); + const recovery = { grantId: stopped.grantId, reservationId: stopped.reservation.reservationId, + dispatchAttemptId: stopped.reservation.dispatchAttemptId }; + await assert.rejects(recoverReservedActionGrantV1(profile.grants, recovery, async () => false)); + const recovered = await recoverReservedActionGrantV1(profile.grants, recovery, async () => child.exitCode === 70); + assert.equal(recovered.status, 'indeterminate'); + const receipt = await profile.admission.reconcile(scope.tenantId, 'effect:persistent', async effect => { + const result = await pool.query(`SELECT request_digest FROM "${schema}".external_receipts WHERE effect_id=$1`, [effect.request.effectId]); + return result.rows[0]?.request_digest === effect.requestDigest ? { + requestDigest: effect.requestDigest, outcome: 'succeeded', proofRef: 'receipt:stopped-worker' } : null; + }); + assert.equal(receipt.status, 'succeeded'); + await reconcileActionGrantV1(profile.grants, { ...recovery, outcome: 'dispatched' }); + let duplicates = 0; + await assert.rejects(invoke(gateway(profile, async () => { duplicates++; return { ok: true }; }))); + assert.equal(duplicates, 0); + assert.equal((await pool.query(`SELECT count(*)::int AS count FROM "${schema}".external_receipts`)).rows[0].count, 1); + } finally { await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); await pool.end(); } +}); diff --git a/tests/inference-control-action-effects.test.mjs b/tests/inference-control-action-effects.test.mjs new file mode 100644 index 00000000..fc159c46 --- /dev/null +++ b/tests/inference-control-action-effects.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createConditionalActionDispatcherV1 } from '../packages/inference-control/dist/action-effects.js'; +import { actionInputDigest, controlDigest } from '../packages/inference-control/dist/tools.js'; +const capabilities = { executorId: 'test', executorVersion: 1, atomicPreconditions: true, idempotency: 'native', receiptLookup: true }; +const input = { input: { value: 2 }, permit: { idempotencyKey: 'key', actionDigest: `sha256:${'1'.repeat(64)}` } }; +const preconditions = { version: 1 }, approvedPreconditionsDigest = controlDigest('grant', preconditions); +function wrapper(execute, changes = {}) { + return createConditionalActionDispatcherV1({ dispatcherId: 'dispatcher', dispatcherVersion: 1, fencingMode: 'local_only', + port: { capabilities, execute }, resolvePreconditions: async () => ({ preconditions, approvedPreconditionsDigest }), ...changes }); +} +function receipt(outcome = 'succeeded') { + return { schemaVersion: 1, idempotencyKey: 'key', actionDigest: input.permit.actionDigest, + inputDigest: actionInputDigest(input.input), preconditionsDigest: approvedPreconditionsDigest, + outcome, proofRef: 'receipt:1', result: { ok: outcome === 'succeeded' } }; +} +test('conditional dispatcher requires capabilities and binds exact reviewed preconditions', async () => { + for (const change of [{ atomicPreconditions: false }, { idempotency: 'none' }, { receiptLookup: false }]) + assert.throws(() => wrapper(async () => receipt(), { port: { capabilities: { ...capabilities, ...change }, execute: async () => receipt() } })); + let effects = 0; + const d = wrapper(async q => { effects++; assert.deepEqual(q.preconditions, preconditions); return receipt(); }); + assert.equal((await d.dispatch(input)).ok, true); + await assert.rejects(wrapper(async () => { effects++; return receipt(); }, { + resolvePreconditions: async () => ({ preconditions: { version: 2 }, approvedPreconditionsDigest }), + }).dispatch(input)); + assert.equal(effects, 1); +}); +test('uncertain and uncorrelated receipts never become verified successes', async () => { + for (const change of [{ idempotencyKey: 'other' }, { actionDigest: 'other' }, { inputDigest: 'other' }, + { preconditionsDigest: 'other' }, { outcome: 'unknown' }, { proofRef: '' }, + { outcome: 'not_applied', result: { ok: true } }]) + await assert.rejects(wrapper(async () => ({ ...receipt(), ...change })).dispatch(input)); + await assert.rejects(wrapper(async () => { throw Error('timeout'); }).dispatch(input), /timeout/); + assert.equal((await wrapper(async () => receipt('not_applied')).dispatch(input)).ok, false); +}); diff --git a/tests/inference-control-action-effects.test.mts b/tests/inference-control-action-effects.test.mts new file mode 100644 index 00000000..c3e06cf2 --- /dev/null +++ b/tests/inference-control-action-effects.test.mts @@ -0,0 +1,8 @@ +import { createConditionalActionDispatcherV1, type ActionConditionalExecutionPortV1 } + from '@agentplat/inference-control/action-effects'; +import type { ActionDispatcher } from '@agentplat/inference-control/tools'; +declare const port: ActionConditionalExecutionPortV1; +declare const resolvePreconditions: Parameters[0]['resolvePreconditions']; +const dispatcher: ActionDispatcher = createConditionalActionDispatcherV1({ dispatcherId: 'conditional', + dispatcherVersion: 1, fencingMode: 'local_only', port, resolvePreconditions }); +void dispatcher; diff --git a/tests/inference-control-gateways.test.mts b/tests/inference-control-gateways.test.mts index c92cfc2f..bd6c38f9 100644 --- a/tests/inference-control-gateways.test.mts +++ b/tests/inference-control-gateways.test.mts @@ -6,6 +6,8 @@ import type { ActionScope, } from "@agentplat/inference-control/tools"; import { + recoverReservedActionGrantV1, + createActionGrantV1, issueActionGrantV1, reconcileActionGrantV1, } from "@agentplat/inference-control/tools"; @@ -35,3 +37,17 @@ void issueActionGrantV1; void reconcileActionGrantV1; void messageGateway; void messagePermit; + +const prepared = createActionGrantV1({ + grantId: "grant:prepared", scope, binding: actionGateway.binding, input: {}, + assessmentRequestId: "assessment-request:prepared", assessmentId: "assessment:prepared", + assessmentTargetDigest: `sha256:${"2".repeat(64)}`, idempotencyKey: "effect:prepared", + issuedAtLogicalMs: 1, expiresAtLogicalMs: 101, +}); +void issueActionGrantV1(actionGrantRepository, prepared); +// @ts-expect-error callers cannot mutate a prepared grant +prepared.status = "dispatched"; + +void recoverReservedActionGrantV1(actionGrantRepository, { + grantId: "one", reservationId: "one:reservation", dispatchAttemptId: "one:attempt", +}, async grant => grant.reservation !== null); diff --git a/tests/inference-control-grant-builder.test.mjs b/tests/inference-control-grant-builder.test.mjs new file mode 100644 index 00000000..0b880d9a --- /dev/null +++ b/tests/inference-control-grant-builder.test.mjs @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createActionGrantV1, + ActionGateway, + LocalGrantLedger, + actionDigest, + actionInputDigest, + scopeDigest, +} from '../packages/inference-control/dist/tools.js'; +import { + LocalMessageAttemptLedger, + OutboundMessageGateway, + outboundMessageDigest, +} from '../packages/inference-control/dist/messages.js'; + +const scope = Object.freeze({ + schemaVersion: 1, + kind: 'standalone', + tenantId: 'tenant:one', + runId: 'run:one', + agentId: 'agent:one', + organizationId: null, + workspaceId: null, + policyId: 'policy:one', + policyVersion: 1, +}); +const binding = Object.freeze({ + schemaVersion: 1, + actionBindingId: 'action-binding:one', + actionBindingVersion: 1, + namespace: 'files', + toolId: 'tool:write', + operation: 'write', + dispatcherId: 'dispatcher:one', + dispatcherVersion: 1, + contextResolverId: 'context-resolver:one', + contextResolverVersion: 1, + fencingMode: 'local_only', + handlerDigest: `sha256:${'1'.repeat(64)}`, +}); + +function grant( + id = 'grant:one', + input = {}, + grantScope = scope, + grantBinding = binding, +) { + const provisional = { + schemaVersion: 1, + grantId: id, + stateGeneration: 1, + scope: grantScope, + scopeDigest: scopeDigest(grantScope), + namespace: grantBinding.namespace, + toolId: grantBinding.toolId, + operation: grantBinding.operation, + actionBindingId: grantBinding.actionBindingId, + actionBindingVersion: grantBinding.actionBindingVersion, + handlerDigest: grantBinding.handlerDigest, + inputDigest: actionInputDigest(input), + actionDigest: '', + assessmentRequestId: 'assessment-request:one', + assessmentId: 'assessment:one', + assessmentTargetDigest: `sha256:${'2'.repeat(64)}`, + idempotencyKey: `idempotency:${id}`, + issuedAtLogicalMs: 1, + expiresAtLogicalMs: 101, + singleUse: true, + status: 'issued', + reservation: null, + }; + return Object.freeze({ + ...provisional, + actionDigest: actionDigest(provisional, grantBinding), + }); +} + +function coordinatedScope() { + return { + schemaVersion: 1, + kind: 'coordinated', + tenantId: 'tenant:one', + runId: 'run:one', + agentId: 'agent:one', + policyId: 'policy:one', + policyVersion: 1, + meshId: 'mesh:one', + objectiveId: 'objective:one', + objectiveRevision: 1, + workItemId: 'work:one', + workItemRevision: 1, + peerId: 'peer:one', + instanceId: 'instance:one', + assignmentAuthorityId: 'authority:one', + assignmentEpoch: 1, + fencingToken: 'fence:one', + leaseExpiresAtLogicalMs: 100, + authorityGeneration: 1, + objectiveTerminal: false, + workTerminal: false, + }; +} + +function authorityCurrent( + resolverId, + resolverVersion, + currentScope, + actionDigestValue, +) { + return { + schemaVersion: 1, + status: 'current', + resolverId, + resolverVersion, + scopeDigest: scopeDigest(currentScope), + actionDigest: actionDigestValue, + scope: currentScope, + authorityGeneration: + currentScope.kind === 'coordinated' + ? currentScope.authorityGeneration + : null, + fencingToken: + currentScope.kind === 'coordinated' ? currentScope.fencingToken : null, + }; +} +function authorityStale( + resolverId, + resolverVersion, + currentScope, + actionDigestValue, +) { + return { + schemaVersion: 1, + status: 'stale', + resolverId, + resolverVersion, + scopeDigest: scopeDigest(currentScope), + actionDigest: actionDigestValue, + }; +} + +function gateway(ledger, dispatch, limits) { + return new ActionGateway( + ledger, + binding, + { + dispatcherId: binding.dispatcherId, + dispatcherVersion: binding.dispatcherVersion, + fencingMode: 'local_only', + dispatch, + }, + { + contextResolverId: binding.contextResolverId, + contextResolverVersion: binding.contextResolverVersion, + async resolve(currentScope) { + return { + tenant: { tenantId: currentScope.tenantId }, + toolId: binding.toolId, + runId: currentScope.runId, + }; + }, + }, + { + resolverId: 'authority:one', + resolverVersion: 1, + async resolve(currentScope, actionDigestValue) { + return authorityCurrent( + 'authority:one', + 1, + currentScope, + actionDigestValue, + ); + }, + }, + { + assessorId: 'assessor:one', + assessorVersion: 1, + async consumeCurrent() { + return true; + }, + }, + limits, + ); +} + +function options(input = {}) { + return { grantId: 'grant:builder', scope, binding, input, + assessmentRequestId: 'assessment-request:one', assessmentId: 'assessment:one', + assessmentTargetDigest: `sha256:${'2'.repeat(64)}`, + idempotencyKey: 'idempotency:grant:builder', issuedAtLogicalMs: 1, + expiresAtLogicalMs: 101 }; +} + +test('builder preserves legacy bytes and gateway accepts the prepared grant', async () => { + const input = { path: '/safe' }; + const prepared = createActionGrantV1(options(input)); + assert.deepEqual(prepared, grant('grant:builder', input)); + const ledger = new LocalGrantLedger('gateway:builder'); + ledger.issue(prepared); + let calls = 0; + await gateway(ledger, async () => { calls++; return { ok: true }; }) + .invoke({ schemaVersion: 1, grantId: prepared.grantId, input, logicalTimeMs: 2 }); + assert.equal(calls, 1); + await assert.rejects(gateway(ledger, async () => { calls++; return { ok: true }; }) + .invoke({ schemaVersion: 1, grantId: prepared.grantId, input, logicalTimeMs: 2 })); + assert.equal(calls, 1); +}); + +test('builder snapshots scope and binds input without mutating callers', () => { + const o = options({ nested: { value: 1 } }); + o.scope = { ...scope }; + const prepared = createActionGrantV1(o); + o.scope.agentId = 'other'; + o.input.nested.value = 2; + assert.equal(prepared.scope.agentId, scope.agentId); + assert.equal(prepared.inputDigest, actionInputDigest({ nested: { value: 1 } })); + assert.ok(Object.isFrozen(prepared)); + assert.ok(Object.isFrozen(prepared.scope)); +}); + +test('builder rejects malformed references, scope, binding, input and lifetimes', () => { + for (const patch of [ + { scope: { ...scope, tenantId: '' } }, + { binding: { ...binding, actionBindingVersion: 0 } }, + { assessmentId: '' }, { assessmentTargetDigest: 'unverified' }, + { issuedAtLogicalMs: -1 }, { expiresAtLogicalMs: 1 }, + { expiresAtLogicalMs: 120002 }, { idempotencyKey: '' }, + { input: { value: NaN } }, { input: { payload: 'x'.repeat(65536) } }, + ]) assert.throws(() => createActionGrantV1({ ...options(), ...patch })); +}); + +test('builder supports coordinated scopes without adding execution authority', () => { + const prepared = createActionGrantV1({ ...options(), scope: coordinatedScope() }); + assert.equal(prepared.scope.kind, 'coordinated'); + assert.equal(prepared.reservation, null); + assert.equal(prepared.status, 'issued'); +}); + +test('preparing and issuing a grant cannot bypass the current assessment', async () => { + const ledger = new LocalGrantLedger('gateway:denied'); + const prepared = createActionGrantV1(options()); + ledger.issue(prepared); + let effects = 0; + const g = gateway(ledger, async () => { effects++; return { ok: true }; }); + const denied = new ActionGateway(ledger, binding, g.dispatcher, + g.contextResolver, g.authorityResolver, + { assessorId: 'assessor:one', assessorVersion: 1, async consumeCurrent() { return false; } }); + await assert.rejects(denied.invoke({ schemaVersion: 1, + grantId: prepared.grantId, input: {}, logicalTimeMs: 2 })); + assert.equal(effects, 0); +}); From 384dd781eb7f7d43e8ccdc68e7ef7412ffd7b3c1 Mon Sep 17 00:00:00 2001 From: grishencorp Date: Wed, 30 Sep 2026 19:24:04 -0300 Subject: [PATCH 2/4] Prepare coordinated 1.2.0 release and action-control gates --- .github/workflows/ci.yml | 9 +++ .github/workflows/release-direct.yml | 7 ++ .github/workflows/release.yml | 2 + .github/workflows/verify-npm-release.yml | 6 ++ docs/action-control/release-plan.md | 8 ++- docs/getting-started/adopting-1.2.md | 30 ++++++++ docs/releases/1.2.0-preparation.md | 69 +++++++++++++++++++ package.json | 2 +- packages/a2a/package.json | 2 +- packages/agent-registry-postgres/package.json | 2 +- packages/agent-registry/package.json | 2 +- packages/assessor-typesafe/package.json | 2 +- packages/audit-postgres/package.json | 2 +- packages/audit/package.json | 2 +- packages/auth/package.json | 2 +- packages/autonomy-postgres/package.json | 2 +- packages/autonomy/package.json | 2 +- .../collective-control-postgres/package.json | 2 +- packages/collective-control/package.json | 2 +- .../collective-host-postgres/package.json | 2 +- packages/collective-host/package.json | 2 +- .../package.json | 2 +- packages/collective-membership/package.json | 2 +- packages/collective-planning/package.json | 2 +- .../collective-quorum-postgres/package.json | 2 +- packages/collective-quorum/package.json | 2 +- packages/collective-runtime/package.json | 2 +- .../collective-sync-postgres/package.json | 2 +- packages/collective-sync/package.json | 2 +- packages/core/package.json | 2 +- packages/events/package.json | 2 +- packages/framework/package.json | 2 +- packages/inference-control/package.json | 2 +- packages/interop-postgres/package.json | 2 +- packages/interop/package.json | 2 +- packages/mcp-docs/package.json | 2 +- packages/mcp-runtime/package.json | 2 +- packages/mcp/package.json | 2 +- packages/memory/package.json | 2 +- packages/mesh-conformance/package.json | 2 +- packages/mesh-crypto/package.json | 2 +- packages/mesh-http/package.json | 2 +- packages/mesh-postgres/package.json | 2 +- packages/mesh-protocol/package.json | 2 +- packages/mesh-sim-local/package.json | 2 +- packages/mesh-sim-postgres/package.json | 2 +- packages/mesh-sim/package.json | 2 +- packages/mesh/package.json | 2 +- packages/model-anthropic/package.json | 2 +- packages/model-gemini/package.json | 2 +- packages/model-openai-compatible/package.json | 2 +- packages/model/package.json | 2 +- .../planning-artifacts-postgres/package.json | 2 +- packages/planning-artifacts/package.json | 2 +- packages/postgres/package.json | 2 +- packages/provider-openai/package.json | 2 +- packages/rooms-api/package.json | 2 +- packages/rooms-mesh/package.json | 2 +- packages/rooms-postgres/package.json | 2 +- packages/rooms-temporal/package.json | 2 +- packages/rooms/package.json | 2 +- packages/runtime-mock/package.json | 2 +- packages/runtime/package.json | 2 +- packages/sessions-redis/package.json | 2 +- packages/sessions/package.json | 2 +- packages/streaming/package.json | 2 +- packages/tools/package.json | 2 +- packages/trust/package.json | 2 +- packages/work-management-asana/package.json | 2 +- packages/workflows-conformance/package.json | 2 +- packages/workflows-postgres/package.json | 2 +- packages/workflows-rooms/package.json | 2 +- packages/workflows-temporal/package.json | 2 +- packages/workflows/package.json | 2 +- pnpm-lock.yaml | 35 +++++----- pnpm-workspace.yaml | 5 +- scripts/release-line.mjs | 10 ++- scripts/verify-public-consumer.mjs | 4 +- tests/release-line.test.mjs | 14 ++++ 79 files changed, 244 insertions(+), 89 deletions(-) create mode 100644 docs/getting-started/adopting-1.2.md create mode 100644 docs/releases/1.2.0-preparation.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8658830..a9751a3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,15 @@ jobs: PGPASSWORD: agentplat_test - name: Verify optional Jev example and experiment contracts run: node --test examples/rooms-api/test/proposal-reviewer.test.mjs experiments/jev-artifact-review/run.test.mjs + - name: Verify standalone action control and independent consumers + run: pnpm run verify:action-control + env: + AGENTPLAT_POSTGRES_TEST: "1" + PGHOST: 127.0.0.1 + PGPORT: "5432" + PGDATABASE: agentplat_test + PGUSER: agentplat_test + PGPASSWORD: agentplat_test - name: Verify purpose governance and packed consumers run: | pnpm run verify:purpose-governance /tmp/purpose-governance-ci diff --git a/.github/workflows/release-direct.yml b/.github/workflows/release-direct.yml index f5997365..cd645d1d 100644 --- a/.github/workflows/release-direct.yml +++ b/.github/workflows/release-direct.yml @@ -95,6 +95,7 @@ jobs: pnpm --filter @agentplat/collective-runtime --filter @agentplat/audit type-check else pnpm run check + pnpm run verify:action-control pnpm run verify:mesh-postgres-faults pnpm run verify:mesh-soak -- --messages 9 --repetitions 2 pnpm run benchmark:mesh-adapters @@ -122,6 +123,7 @@ jobs: pnpm run verify:pack pnpm run verify:purpose-consumer pnpm run verify:jev-consumer + pnpm run verify:action-control-consumer fi env: AGENTPLAT_PREPACKED_TARBALL_DIRECTORY: ${{ github.workspace }}/release-artifacts @@ -224,6 +226,11 @@ jobs: run: pnpm run verify:public-consumer env: AGENTPLAT_PUBLIC_CONSUMER_SOURCE: registry + - name: Verify standalone action-control registry entry points and types + if: ${{ inputs.scope == 'all' }} + run: pnpm run verify:action-control-consumer + env: + AGENTPLAT_PUBLIC_CONSUMER_SOURCE: registry - name: Verify complete coordinated registry distribution if: ${{ inputs.scope == 'all' }} run: node scripts/npm-distribution-readiness.mjs --require-complete --tag "$NPM_DIST_TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 591aa55e..1754e245 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,6 +87,7 @@ jobs: pnpm --filter @agentplat/collective-runtime --filter @agentplat/audit type-check else pnpm run check + pnpm run verify:action-control pnpm run verify:mesh-postgres-faults pnpm run verify:mesh-soak -- --messages 9 --repetitions 2 pnpm run benchmark:mesh-adapters @@ -113,6 +114,7 @@ jobs: pnpm run verify:public-consumer else pnpm run verify:pack + pnpm run verify:action-control-consumer fi env: AGENTPLAT_PREPACKED_TARBALL_DIRECTORY: ${{ github.workspace }}/release-artifacts diff --git a/.github/workflows/verify-npm-release.yml b/.github/workflows/verify-npm-release.yml index 8c35310b..f944aabb 100644 --- a/.github/workflows/verify-npm-release.yml +++ b/.github/workflows/verify-npm-release.yml @@ -100,6 +100,12 @@ jobs: env: AGENTPLAT_PUBLIC_CONSUMER_SOURCE: registry NPM_CONFIG_USERCONFIG: /dev/null + - name: Verify standalone action-control registry entry points and types + if: ${{ inputs.scope == 'all' }} + run: pnpm run verify:action-control-consumer + env: + AGENTPLAT_PUBLIC_CONSUMER_SOURCE: registry + NPM_CONFIG_USERCONFIG: /dev/null - name: Verify complete coordinated public distribution if: ${{ inputs.scope == 'all' }} run: node scripts/npm-distribution-readiness.mjs --require-complete --tag "${{ inputs.dist_tag }}" diff --git a/docs/action-control/release-plan.md b/docs/action-control/release-plan.md index 1f89daf0..5433891e 100644 --- a/docs/action-control/release-plan.md +++ b/docs/action-control/release-plan.md @@ -1,5 +1,9 @@ # Coordinated delivery and publication plan +Publication was authorized by the owner on 2026-09-30. The next coordinated +candidate is 1.2.0; see ../releases/1.2.0-preparation.md. The source qualification +record refers to the preceding 7dc725db increment, before version/workflow changes. + ## Release boundary The additions are opt-in source APIs, not npm 1.1.0 functionality. Never attempt @@ -38,7 +42,9 @@ Use the existing `scripts/set-version.mjs`, approved-source verification, artifact preparation and release-level deployment approval processes described in `docs/security/npm-direct-release.md` and `docs/security/npm-release-security.md`. Preserve protected main, exact staged-byte checks, OIDC and npm authentication -boundaries. This goal does not authorize registry publication or cloud deployment. +boundaries. The original source-support goal did not authorize publication. The subsequent +owner request now authorizes the coordinated npm release; ACL cloud deployment +remains outside this release. After actual publication, verify registry bytes and public consumption, then update ACL to the published coordinated dependency version and run its gateway diff --git a/docs/getting-started/adopting-1.2.md b/docs/getting-started/adopting-1.2.md new file mode 100644 index 00000000..ff32f28c --- /dev/null +++ b/docs/getting-started/adopting-1.2.md @@ -0,0 +1,30 @@ +# Adopting AgentPlat 1.2.0 + +Status: release candidate; install from npm only after verified distribution. + +1. Upgrade the AgentPlat packages your application uses together to 1.2.0 and keep + a lockfile. Existing applications retain their configured behavior. +2. For standalone controlled actions, import the optional approval/admission/effect + subpaths described in [the integration guide](../action-control/integration.md). +3. Back up before explicitly invoking the new PostgreSQL migration runners. Existing + collective/Room migrations and historical data are not rewritten or enrolled. +4. Configure verified host identity, approvers, policy, clocks, trusted resource facts, + quotes, all applicable accounts and persistent grant-to-approval mapping. +5. Route effects through the existing ActionGateway composed with admission and, + where supported, destination-atomic conditional execution. Required controls must + fail closed if storage, facts or capabilities are missing. +6. Verify suspension, exact review, resource changes, competing budgets, process loss + and receipt-based reconciliation in the application's environment before enabling + real actions. Unknown outcomes never automatically retry/refund. + +For The Agent Control, the proposed dependency update after publication is: + +```sh +npm install --save-exact @agentplat/inference-control@1.2.0 \ + @agentplat/collective-control-postgres@1.2.0 +``` + +Keep MCP routing, connectors, UI, authentication implementation, secret custody, +commercial licensing and portal logic in ACL. AgentPlat supplies public mechanisms; +ACL owns their authenticated server integration. The installation need not depend +on a remote portal for action authorization. diff --git a/docs/releases/1.2.0-preparation.md b/docs/releases/1.2.0-preparation.md new file mode 100644 index 00000000..1ff9c0cd --- /dev/null +++ b/docs/releases/1.2.0-preparation.md @@ -0,0 +1,69 @@ +# AgentPlat 1.2.0 — release preparation + +Status: owner-authorized publication in progress. The candidate preserves the +66-package coordinated cohort and targets `latest`. It is not yet a registry release. + +## Scope + +Standalone governed external actions compose existing ActionGateway/grant owners +without requiring Rooms: exact-target independent human approval, optional PostgreSQL +approval storage, transactional shared budgets and revocation fences, explicit +conditional execution contracts and receipt-based recovery. New surfaces are opt-in; +existing default behavior, exports, type contracts and historical migrations remain. + +The typed grant builder and conservative reserved-grant recovery extend the existing +`./tools` entry point. Five new entry points cover approval, admission and conditional +execution contracts plus PostgreSQL adapters. No new package or commercial ACL code +is included. No implicit policy activation or credentials are introduced. + +The source qualification was captured in commit `7dc725db` before this version +preparation: 46 focused scenarios, durable integration and independent prepared +consumers; full build/types; 1,480 successful unit tests (one skip and six TODOs); +380 successful adapter tests. The recorded source hashes are historical qualification +of that increment, not a claim of identical 1.2.0 manifests/workflows. + +## Production dependency remediation + +The publication audit found newly reported advisories against the existing locked +versions. Reviewed registry patches pin @grpc/grpc-js 1.14.5 (high/low advisories), +fast-uri 3.1.8 and ip-address 10.7.1 (moderate advisories), using the existing override +mechanism. No audit exception is added. The production audit must pass against +the regenerated frozen lockfile before release. + +## Required candidate and publication checks + +CI adds the real-PostgreSQL `verify:action-control` gate. Full release preparation +retains all previous audits/checks/faults/soak/benchmarks and adds action-control +qualification. The exact prepared tarballs are consumed again through +`AGENTPLAT_PREPACKED_TARBALL_DIRECTORY`, preventing substituted repacks. + +Stable compatibility remains anchored to 1.0.0 and preserves its 65 packages, +216 entry points and 83 unchanged public TypeScript fixtures. The candidate retains +the 66-package 1.1 cohort and adds five entry points. Existing migration/behavior +regressions and Node 22 consumer checks remain required. + +Use the protected release-direct workflow from the integrated main commit: first +`dry_run=true`, scope all, tag latest. After successful preparation, dispatch that +reviewed main source with `dry_run=false`. Review the exact artifact/cohort before +owner environment approval through the existing guarded helper. Preserve protected +main, OIDC, artifact hashes/provenance and npm authentication boundaries. + +Distribution is complete only when all 66 registry versions, tags, signatures, +artifact bytes and provenance fields match, and registry consumers pass. Automatic +verification includes new action-control exports/types plus the existing portable, +PostgreSQL and Node 22 consumers. Do not announce success from publication progress. + +## Adoption + +After distribution is verified, consumers update their coordinated dependencies +together. ACL needs only packages it uses, notably inference-control and +collective-control-postgres, pinned to the published coordinated version. + +Existing installations do not run new migrations automatically. Applications choosing +the standalone profile invoke the optional migration runners, install trusted identity, +policy/fact/clock/quote ports, retain exact requests and grant-to-approval mapping, then +activate their effect path. Read [integration](../action-control/integration.md). + +The reference admission adapter is tenant-serialized with retained history. Atomic +resource preconditions/idempotency depend on real destination adapters, not declarations +alone. Tests are bounded software evidence, not production-scale validation. diff --git a/package.json b/package.json index b7e3d919..8d6c090c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentplat", - "version": "1.1.0", + "version": "1.2.0", "private": true, "description": "Open-source TypeScript framework for persistent human-agent collaboration, with shared artifacts, human approvals and controlled execution on your infrastructure.", "keywords": [ diff --git a/packages/a2a/package.json b/packages/a2a/package.json index a64923cf..33b0e21d 100644 --- a/packages/a2a/package.json +++ b/packages/a2a/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/a2a", - "version": "1.1.0", + "version": "1.2.0", "description": "Governed A2A 1.0 interoperability for AgentPlat agents and Room services.", "type": "module", "license": "Apache-2.0", diff --git a/packages/agent-registry-postgres/package.json b/packages/agent-registry-postgres/package.json index 276be681..c5a3a98c 100644 --- a/packages/agent-registry-postgres/package.json +++ b/packages/agent-registry-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/agent-registry-postgres", - "version": "1.1.0", + "version": "1.2.0", "description": "PostgreSQL persistence for the AgentPlat Agent Registry.", "type": "module", "license": "Apache-2.0", diff --git a/packages/agent-registry/package.json b/packages/agent-registry/package.json index 4db1707e..d7a320cb 100644 --- a/packages/agent-registry/package.json +++ b/packages/agent-registry/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/agent-registry", - "version": "1.1.0", + "version": "1.2.0", "description": "Tenant-scoped capability discovery with revisioned agent descriptors.", "type": "module", "license": "Apache-2.0", diff --git a/packages/assessor-typesafe/package.json b/packages/assessor-typesafe/package.json index 95141bf3..601c413c 100644 --- a/packages/assessor-typesafe/package.json +++ b/packages/assessor-typesafe/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/assessor-typesafe", - "version": "1.1.0", + "version": "1.2.0", "description": "Optional TypeSafe Jev assessor adapter for AgentPlat Inference Control.", "type": "module", "license": "Apache-2.0", diff --git a/packages/audit-postgres/package.json b/packages/audit-postgres/package.json index 5669c5bb..46fc5741 100644 --- a/packages/audit-postgres/package.json +++ b/packages/audit-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/audit-postgres", - "version": "1.1.0", + "version": "1.2.0", "description": "PostgreSQL audit and session event sinks for AgentPlat without Agent Rooms.", "type": "module", "license": "Apache-2.0", diff --git a/packages/audit/package.json b/packages/audit/package.json index 3a9be5bc..0719e75d 100644 --- a/packages/audit/package.json +++ b/packages/audit/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/audit", - "version": "1.1.0", + "version": "1.2.0", "description": "Audit contracts, recursive redaction and an in-memory sink for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/auth/package.json b/packages/auth/package.json index 6d05a422..5d5e4681 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/auth", - "version": "1.1.0", + "version": "1.2.0", "description": "Provider-neutral auth and tenant-resolution contracts for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/autonomy-postgres/package.json b/packages/autonomy-postgres/package.json index 9ef6090e..2f95ffd4 100644 --- a/packages/autonomy-postgres/package.json +++ b/packages/autonomy-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/autonomy-postgres", - "version": "1.1.0", + "version": "1.2.0", "description": "PostgreSQL state and decision journal for AgentPlat progressive autonomy.", "type": "module", "license": "Apache-2.0", diff --git a/packages/autonomy/package.json b/packages/autonomy/package.json index 07f062bc..a9d3cbd4 100644 --- a/packages/autonomy/package.json +++ b/packages/autonomy/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/autonomy", - "version": "1.1.0", + "version": "1.2.0", "description": "Evidence-gated progressive supervision for governed AgentPlat actions.", "type": "module", "license": "Apache-2.0", diff --git a/packages/collective-control-postgres/package.json b/packages/collective-control-postgres/package.json index 09a6086a..22bc32f7 100644 --- a/packages/collective-control-postgres/package.json +++ b/packages/collective-control-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/collective-control-postgres", - "version": "1.1.0", + "version": "1.2.0", "description": "PostgreSQL repositories and migrations for Agentplat collective control.", "type": "module", "license": "Apache-2.0", diff --git a/packages/collective-control/package.json b/packages/collective-control/package.json index bdb60090..e8024784 100644 --- a/packages/collective-control/package.json +++ b/packages/collective-control/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/collective-control", - "version": "1.1.0", + "version": "1.2.0", "description": "Provider-neutral governed delegation, work and action contracts for AgentPlat collectives.", "type": "module", "license": "Apache-2.0", diff --git a/packages/collective-host-postgres/package.json b/packages/collective-host-postgres/package.json index 4608cc6a..1ef88e22 100644 --- a/packages/collective-host-postgres/package.json +++ b/packages/collective-host-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/collective-host-postgres", - "version": "1.1.0", + "version": "1.2.0", "description": "PostgreSQL durability for Agentplat collective host runtimes and protocol artifacts.", "type": "module", "license": "Apache-2.0", diff --git a/packages/collective-host/package.json b/packages/collective-host/package.json index efb66577..2732440f 100644 --- a/packages/collective-host/package.json +++ b/packages/collective-host/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/collective-host", - "version": "1.1.0", + "version": "1.2.0", "description": "Peer-local composition host for decentralized planning, control, trust and governed evolution.", "type": "module", "license": "Apache-2.0", diff --git a/packages/collective-membership-postgres/package.json b/packages/collective-membership-postgres/package.json index 3361b606..4218a95c 100644 --- a/packages/collective-membership-postgres/package.json +++ b/packages/collective-membership-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/collective-membership-postgres", - "version": "1.1.0", + "version": "1.2.0", "description": "PostgreSQL durability for certified Agentplat membership epochs and key rotation.", "type": "module", "license": "Apache-2.0", diff --git a/packages/collective-membership/package.json b/packages/collective-membership/package.json index 8c8d2193..8a143f7d 100644 --- a/packages/collective-membership/package.json +++ b/packages/collective-membership/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/collective-membership", - "version": "1.1.0", + "version": "1.2.0", "description": "Certified dynamic peer membership and overlapping key rotation for Agentplat collectives.", "type": "module", "license": "Apache-2.0", diff --git a/packages/collective-planning/package.json b/packages/collective-planning/package.json index 33ba4c0b..9ec04a70 100644 --- a/packages/collective-planning/package.json +++ b/packages/collective-planning/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/collective-planning", - "version": "1.1.0", + "version": "1.2.0", "description": "Portable contracts and deterministic primitives for distributed mission planning.", "type": "module", "license": "Apache-2.0", diff --git a/packages/collective-quorum-postgres/package.json b/packages/collective-quorum-postgres/package.json index 539bd81b..469ee187 100644 --- a/packages/collective-quorum-postgres/package.json +++ b/packages/collective-quorum-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/collective-quorum-postgres", - "version": "1.1.0", + "version": "1.2.0", "description": "PostgreSQL durability for Agentplat signed peer quorum protocols.", "type": "module", "license": "Apache-2.0", diff --git a/packages/collective-quorum/package.json b/packages/collective-quorum/package.json index ae0a036b..2d5bc601 100644 --- a/packages/collective-quorum/package.json +++ b/packages/collective-quorum/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/collective-quorum", - "version": "1.1.0", + "version": "1.2.0", "description": "Signed peer quorum protocols and Agentplat collective runtime adapters.", "type": "module", "license": "Apache-2.0", diff --git a/packages/collective-runtime/package.json b/packages/collective-runtime/package.json index a31df322..0acda79e 100644 --- a/packages/collective-runtime/package.json +++ b/packages/collective-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/collective-runtime", - "version": "1.1.0", + "version": "1.2.0", "description": "High-level provider-neutral runtime for capability-based AgentPlat collectives.", "type": "module", "license": "Apache-2.0", diff --git a/packages/collective-sync-postgres/package.json b/packages/collective-sync-postgres/package.json index c849ffa1..225154a8 100644 --- a/packages/collective-sync-postgres/package.json +++ b/packages/collective-sync-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/collective-sync-postgres", - "version": "1.1.0", + "version": "1.2.0", "description": "Transactional PostgreSQL persistence for Agentplat collective causal synchronization.", "type": "module", "license": "Apache-2.0", diff --git a/packages/collective-sync/package.json b/packages/collective-sync/package.json index 9bae75ba..ea6cdaff 100644 --- a/packages/collective-sync/package.json +++ b/packages/collective-sync/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/collective-sync", - "version": "1.1.0", + "version": "1.2.0", "description": "Authenticated causal anti-entropy, resumable catch-up, and readiness evidence for Agentplat collectives.", "type": "module", "license": "Apache-2.0", diff --git a/packages/core/package.json b/packages/core/package.json index 4ef58d7b..e44a8a95 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/core", - "version": "1.1.0", + "version": "1.2.0", "description": "Foundational types, errors and tenant context for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/events/package.json b/packages/events/package.json index e8e4c8d7..d8c3dd50 100644 --- a/packages/events/package.json +++ b/packages/events/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/events", - "version": "1.1.0", + "version": "1.2.0", "description": "Event contracts and an in-memory event bus for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/framework/package.json b/packages/framework/package.json index d935f1f0..5ae67d03 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/framework", - "version": "1.1.0", + "version": "1.2.0", "description": "Open-source TypeScript framework for persistent human-agent collaboration, with shared artifacts, human approvals and controlled execution on your infrastructure.", "type": "module", "license": "Apache-2.0", diff --git a/packages/inference-control/package.json b/packages/inference-control/package.json index b65ae3f6..b2edfd1c 100644 --- a/packages/inference-control/package.json +++ b/packages/inference-control/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/inference-control", - "version": "1.1.0", + "version": "1.2.0", "description": "Deterministic inference-control contracts and reducer for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/interop-postgres/package.json b/packages/interop-postgres/package.json index edae0abd..3c07fc73 100644 --- a/packages/interop-postgres/package.json +++ b/packages/interop-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/interop-postgres", - "version": "1.1.0", + "version": "1.2.0", "description": "PostgreSQL custody for governed Agentplat interop sessions and outbound sequences.", "type": "module", "license": "Apache-2.0", diff --git a/packages/interop/package.json b/packages/interop/package.json index bcc383c2..0e543685 100644 --- a/packages/interop/package.json +++ b/packages/interop/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/interop", - "version": "1.1.0", + "version": "1.2.0", "description": "Provider-neutral SDK for connecting external agents and simulation environments to Agentplat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/mcp-docs/package.json b/packages/mcp-docs/package.json index 6bcc9092..a2b0f9e4 100644 --- a/packages/mcp-docs/package.json +++ b/packages/mcp-docs/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/mcp-docs", - "version": "1.1.0", + "version": "1.2.0", "description": "Read-only MCP documentation server for AgentPlat specifications, concepts, APIs and examples.", "keywords": [ "mcp", diff --git a/packages/mcp-runtime/package.json b/packages/mcp-runtime/package.json index 7d2fbd09..7c306cdf 100644 --- a/packages/mcp-runtime/package.json +++ b/packages/mcp-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/mcp-runtime", - "version": "1.1.0", + "version": "1.2.0", "description": "Self-hosted MCP adapter for operating an authorized AgentPlat runtime.", "keywords": [ "mcp", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index bd6830c7..95a0bde4 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/mcp", - "version": "1.1.0", + "version": "1.2.0", "description": "MCP server, tool binding and registry contracts for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/memory/package.json b/packages/memory/package.json index 8d7867f1..8154c190 100644 --- a/packages/memory/package.json +++ b/packages/memory/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/memory", - "version": "1.1.0", + "version": "1.2.0", "description": "Memory contracts and an isolated in-memory store for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/mesh-conformance/package.json b/packages/mesh-conformance/package.json index 21b2d80b..a90cb9e7 100644 --- a/packages/mesh-conformance/package.json +++ b/packages/mesh-conformance/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/mesh-conformance", - "version": "1.1.0", + "version": "1.2.0", "description": "Provider-neutral compatibility runners for Agentplat Mesh adapters.", "type": "module", "license": "Apache-2.0", diff --git a/packages/mesh-crypto/package.json b/packages/mesh-crypto/package.json index f1053df6..8bf231da 100644 --- a/packages/mesh-crypto/package.json +++ b/packages/mesh-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/mesh-crypto", - "version": "1.1.0", + "version": "1.2.0", "description": "Web Crypto signing and verification contracts for AgentPlat Mesh envelopes.", "type": "module", "license": "Apache-2.0", diff --git a/packages/mesh-http/package.json b/packages/mesh-http/package.json index 58c6e0f0..7ac83445 100644 --- a/packages/mesh-http/package.json +++ b/packages/mesh-http/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/mesh-http", - "version": "1.1.0", + "version": "1.2.0", "description": "Bounded Fetch-compatible HTTP transport for signed Agentplat Mesh envelopes.", "type": "module", "license": "Apache-2.0", diff --git a/packages/mesh-postgres/package.json b/packages/mesh-postgres/package.json index 69c6fb27..28c56869 100644 --- a/packages/mesh-postgres/package.json +++ b/packages/mesh-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/mesh-postgres", - "version": "1.1.0", + "version": "1.2.0", "description": "PostgreSQL durable inbox, snapshot, journal and outbox adapter for Agentplat Mesh.", "type": "module", "license": "Apache-2.0", diff --git a/packages/mesh-protocol/package.json b/packages/mesh-protocol/package.json index c183c93f..9846afa4 100644 --- a/packages/mesh-protocol/package.json +++ b/packages/mesh-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/mesh-protocol", - "version": "1.1.0", + "version": "1.2.0", "description": "Bounded wire contracts for provider-neutral AgentPlat Mesh peers.", "type": "module", "license": "Apache-2.0", diff --git a/packages/mesh-sim-local/package.json b/packages/mesh-sim-local/package.json index 7c05efef..c684cee3 100644 --- a/packages/mesh-sim-local/package.json +++ b/packages/mesh-sim-local/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/mesh-sim-local", - "version": "1.1.0", + "version": "1.2.0", "description": "Local content-addressed evidence store for Agentplat Mesh simulation campaigns.", "type": "module", "license": "Apache-2.0", diff --git a/packages/mesh-sim-postgres/package.json b/packages/mesh-sim-postgres/package.json index 24150bb6..6dffc038 100644 --- a/packages/mesh-sim-postgres/package.json +++ b/packages/mesh-sim-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/mesh-sim-postgres", - "version": "1.1.0", + "version": "1.2.0", "description": "PostgreSQL campaign custody, fenced execution, and restart-durable scalable evaluation checkpoints for AgentPlat Mesh simulation.", "type": "module", "license": "Apache-2.0", diff --git a/packages/mesh-sim/package.json b/packages/mesh-sim/package.json index 574b653f..ad132e3d 100644 --- a/packages/mesh-sim/package.json +++ b/packages/mesh-sim/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/mesh-sim", - "version": "1.1.0", + "version": "1.2.0", "description": "Deterministic simulation contracts for AgentPlat Mesh peer state machines.", "type": "module", "license": "Apache-2.0", diff --git a/packages/mesh/package.json b/packages/mesh/package.json index 8b546a30..e2fdb5b5 100644 --- a/packages/mesh/package.json +++ b/packages/mesh/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/mesh", - "version": "1.1.0", + "version": "1.2.0", "description": "Provider-neutral peer state and effect contracts for AgentPlat Mesh applications.", "type": "module", "license": "Apache-2.0", diff --git a/packages/model-anthropic/package.json b/packages/model-anthropic/package.json index 425a9166..b735560f 100644 --- a/packages/model-anthropic/package.json +++ b/packages/model-anthropic/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/model-anthropic", - "version": "1.1.0", + "version": "1.2.0", "description": "Dependency-light native Anthropic Messages API model adapter for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/model-gemini/package.json b/packages/model-gemini/package.json index cbcd1eb8..5db76830 100644 --- a/packages/model-gemini/package.json +++ b/packages/model-gemini/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/model-gemini", - "version": "1.1.0", + "version": "1.2.0", "description": "Dependency-light native Gemini GenerateContent API model adapter for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/model-openai-compatible/package.json b/packages/model-openai-compatible/package.json index cee7869e..abda2d08 100644 --- a/packages/model-openai-compatible/package.json +++ b/packages/model-openai-compatible/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/model-openai-compatible", - "version": "1.1.0", + "version": "1.2.0", "description": "Dependency-light OpenAI-compatible Chat Completions model adapter for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/model/package.json b/packages/model/package.json index e281007a..4f0409b0 100644 --- a/packages/model/package.json +++ b/packages/model/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/model", - "version": "1.1.0", + "version": "1.2.0", "description": "Provider-neutral model generation and streaming contracts for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/planning-artifacts-postgres/package.json b/packages/planning-artifacts-postgres/package.json index d36afedf..0396a21e 100644 --- a/packages/planning-artifacts-postgres/package.json +++ b/packages/planning-artifacts-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/planning-artifacts-postgres", - "version": "1.1.0", + "version": "1.2.0", "description": "Immutable PostgreSQL persistence for Agentplat planning artifacts.", "type": "module", "license": "Apache-2.0", diff --git a/packages/planning-artifacts/package.json b/packages/planning-artifacts/package.json index d3f96767..e7dd7eed 100644 --- a/packages/planning-artifacts/package.json +++ b/packages/planning-artifacts/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/planning-artifacts", - "version": "1.1.0", + "version": "1.2.0", "description": "Authenticated publication and peer-to-peer availability for Agentplat planning artifacts.", "type": "module", "license": "Apache-2.0", diff --git a/packages/postgres/package.json b/packages/postgres/package.json index e2d4d823..8f1619ee 100644 --- a/packages/postgres/package.json +++ b/packages/postgres/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/postgres", - "version": "1.1.0", + "version": "1.2.0", "description": "Shared PostgreSQL pool, health and migration primitives for AgentPlat adapters.", "type": "module", "license": "Apache-2.0", diff --git a/packages/provider-openai/package.json b/packages/provider-openai/package.json index d1a2d1cc..759a590f 100644 --- a/packages/provider-openai/package.json +++ b/packages/provider-openai/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/provider-openai", - "version": "1.1.0", + "version": "1.2.0", "description": "OpenAI Agents SDK provider for the AgentPlat runtime.", "type": "module", "license": "Apache-2.0", diff --git a/packages/rooms-api/package.json b/packages/rooms-api/package.json index a066a153..e03e1165 100644 --- a/packages/rooms-api/package.json +++ b/packages/rooms-api/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/rooms-api", - "version": "1.1.0", + "version": "1.2.0", "description": "Injectable Hono HTTP API for AgentPlat Agent Rooms.", "type": "module", "license": "Apache-2.0", diff --git a/packages/rooms-mesh/package.json b/packages/rooms-mesh/package.json index 04e98866..f53c5f26 100644 --- a/packages/rooms-mesh/package.json +++ b/packages/rooms-mesh/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/rooms-mesh", - "version": "1.1.0", + "version": "1.2.0", "description": "Explicit authority-neutral projections between Agentplat Rooms and Mesh work.", "type": "module", "license": "Apache-2.0", diff --git a/packages/rooms-postgres/package.json b/packages/rooms-postgres/package.json index 64ee1f1a..8be76e54 100644 --- a/packages/rooms-postgres/package.json +++ b/packages/rooms-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/rooms-postgres", - "version": "1.1.0", + "version": "1.2.0", "description": "PostgreSQL persistence adapter for AgentPlat Agent Rooms.", "type": "module", "license": "Apache-2.0", diff --git a/packages/rooms-temporal/package.json b/packages/rooms-temporal/package.json index a323cc44..ec744fb0 100644 --- a/packages/rooms-temporal/package.json +++ b/packages/rooms-temporal/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/rooms-temporal", - "version": "1.1.0", + "version": "1.2.0", "description": "Optional Temporal adapter for durable AgentPlat Agent Room coordination.", "type": "module", "license": "Apache-2.0", diff --git a/packages/rooms/package.json b/packages/rooms/package.json index a885fa08..aade2a40 100644 --- a/packages/rooms/package.json +++ b/packages/rooms/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/rooms", - "version": "1.1.0", + "version": "1.2.0", "description": "Agent Room domain, lifecycle services and local adapters for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/runtime-mock/package.json b/packages/runtime-mock/package.json index ddf97159..9248b394 100644 --- a/packages/runtime-mock/package.json +++ b/packages/runtime-mock/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/runtime-mock", - "version": "1.1.0", + "version": "1.2.0", "description": "Deterministic mock agent runtime for AgentPlat development and tests.", "type": "module", "license": "Apache-2.0", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 2e6a3dcc..dcdad257 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/runtime", - "version": "1.1.0", + "version": "1.2.0", "description": "Provider-neutral executable agent runtime for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/sessions-redis/package.json b/packages/sessions-redis/package.json index 789828a4..456c9592 100644 --- a/packages/sessions-redis/package.json +++ b/packages/sessions-redis/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/sessions-redis", - "version": "1.1.0", + "version": "1.2.0", "description": "Redis pub/sub SessionRegistry adapter for multi-instance AgentPlat sessions.", "type": "module", "license": "Apache-2.0", diff --git a/packages/sessions/package.json b/packages/sessions/package.json index 3745a188..397fad38 100644 --- a/packages/sessions/package.json +++ b/packages/sessions/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/sessions", - "version": "1.1.0", + "version": "1.2.0", "description": "Typed ephemeral multi-agent sessions for AgentPlat runtimes.", "type": "module", "license": "Apache-2.0", diff --git a/packages/streaming/package.json b/packages/streaming/package.json index 9b3e7e3b..d1dbaf12 100644 --- a/packages/streaming/package.json +++ b/packages/streaming/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/streaming", - "version": "1.1.0", + "version": "1.2.0", "description": "Framework-neutral SSE helpers for AgentPlat runtime streams.", "type": "module", "license": "Apache-2.0", diff --git a/packages/tools/package.json b/packages/tools/package.json index b970b228..23b07016 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/tools", - "version": "1.1.0", + "version": "1.2.0", "description": "Tool contracts and an in-memory tool registry for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/trust/package.json b/packages/trust/package.json index e68cbb68..fe49cc7b 100644 --- a/packages/trust/package.json +++ b/packages/trust/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/trust", - "version": "1.1.0", + "version": "1.2.0", "description": "Browser-safe deterministic evidence and trust primitives for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/packages/work-management-asana/package.json b/packages/work-management-asana/package.json index eb62f400..65be4dd6 100644 --- a/packages/work-management-asana/package.json +++ b/packages/work-management-asana/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/work-management-asana", - "version": "1.1.0", + "version": "1.2.0", "description": "Asana Work Management provider for AgentPlat human contributions.", "type": "module", "license": "Apache-2.0", diff --git a/packages/workflows-conformance/package.json b/packages/workflows-conformance/package.json index 0c54e2d3..4dfaf71c 100644 --- a/packages/workflows-conformance/package.json +++ b/packages/workflows-conformance/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/workflows-conformance", - "version": "1.1.0", + "version": "1.2.0", "description": "Provider-neutral conformance runners for AgentPlat governed workflow adapters.", "type": "module", "license": "Apache-2.0", diff --git a/packages/workflows-postgres/package.json b/packages/workflows-postgres/package.json index 6b764834..f7e30f08 100644 --- a/packages/workflows-postgres/package.json +++ b/packages/workflows-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/workflows-postgres", - "version": "1.1.0", + "version": "1.2.0", "description": "PostgreSQL durability for AgentPlat governed workflows and delayed outcomes.", "type": "module", "license": "Apache-2.0", diff --git a/packages/workflows-rooms/package.json b/packages/workflows-rooms/package.json index f78381af..1ce822e4 100644 --- a/packages/workflows-rooms/package.json +++ b/packages/workflows-rooms/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/workflows-rooms", - "version": "1.1.0", + "version": "1.2.0", "description": "Agent Room approval gates for AgentPlat governed workflows.", "type": "module", "license": "Apache-2.0", diff --git a/packages/workflows-temporal/package.json b/packages/workflows-temporal/package.json index ca414eb6..94ee8a55 100644 --- a/packages/workflows-temporal/package.json +++ b/packages/workflows-temporal/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/workflows-temporal", - "version": "1.1.0", + "version": "1.2.0", "description": "Optional Temporal wakeup, retry and rollover adapter for AgentPlat governed workflows.", "type": "module", "license": "Apache-2.0", diff --git a/packages/workflows/package.json b/packages/workflows/package.json index f27f37c8..8de5623d 100644 --- a/packages/workflows/package.json +++ b/packages/workflows/package.json @@ -1,6 +1,6 @@ { "name": "@agentplat/workflows", - "version": "1.1.0", + "version": "1.2.0", "description": "Workflow contracts and an in-memory workflow store for AgentPlat.", "type": "module", "license": "Apache-2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9700119a..37f0ea93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,9 +5,10 @@ settings: excludeLinksFromLockfile: false overrides: - fast-uri: 3.1.7 + '@grpc/grpc-js': 1.14.5 + fast-uri: 3.1.8 hono: 4.13.9 - ip-address: 10.5.1 + ip-address: 10.7.1 '@hono/node-server': '>=1.19.15' qs: 6.16.0 @@ -1302,8 +1303,8 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} - '@grpc/grpc-js@1.14.4': - resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} + '@grpc/grpc-js@1.14.5': + resolution: {integrity: sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==} engines: {node: '>=12.10.0'} '@grpc/proto-loader@0.8.1': @@ -2070,8 +2071,8 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-uri@3.1.7: - resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} @@ -2155,8 +2156,8 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ip-address@10.5.1: - resolution: {integrity: sha512-EXujUp9jyOI/chPgtqk6uy7fDq8AeCB/WlfEuPg9LN0fN9lzKAKfuDYi60SMhHwgUiEhZvVYsbGZN+RUU1INiA==} + ip-address@10.7.1: + resolution: {integrity: sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -2665,7 +2666,7 @@ snapshots: dependencies: jose: 6.2.3 optionalDependencies: - '@grpc/grpc-js': 1.14.4 + '@grpc/grpc-js': 1.14.5 express: 5.2.1(supports-color@8.1.1) '@babel/helper-string-parser@7.29.7': {} @@ -2681,7 +2682,7 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@grpc/grpc-js@1.14.4': + '@grpc/grpc-js@1.14.5': dependencies: '@grpc/proto-loader': 0.8.1 '@js-sdsl/ordered-map': 4.4.2 @@ -3037,7 +3038,7 @@ snapshots: '@temporalio/client@1.24.0': dependencies: - '@grpc/grpc-js': 1.14.4 + '@grpc/grpc-js': 1.14.5 '@temporalio/common': 1.24.0 '@temporalio/proto': 1.24.0 abort-controller: 3.0.0 @@ -3055,7 +3056,7 @@ snapshots: '@temporalio/core-bridge@1.24.0': dependencies: - '@grpc/grpc-js': 1.14.4 + '@grpc/grpc-js': 1.14.5 '@temporalio/common': 1.24.0 '@temporalio/nexus@1.24.0': @@ -3073,7 +3074,7 @@ snapshots: '@temporalio/worker@1.24.0': dependencies: - '@grpc/grpc-js': 1.14.4 + '@grpc/grpc-js': 1.14.5 '@swc/core': 1.16.1 '@temporalio/activity': 1.24.0 '@temporalio/client': 1.24.0 @@ -3301,7 +3302,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.7 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -3457,7 +3458,7 @@ snapshots: express-rate-limit@8.5.2(express@5.2.1(supports-color@8.1.1)): dependencies: express: 5.2.1(supports-color@8.1.1) - ip-address: 10.5.1 + ip-address: 10.7.1 express@5.2.1(supports-color@8.1.1): dependencies: @@ -3494,7 +3495,7 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-uri@3.1.7: {} + fast-uri@3.1.8: {} finalhandler@2.1.1(supports-color@8.1.1): dependencies: @@ -3575,7 +3576,7 @@ snapshots: inherits@2.0.4: {} - ip-address@10.5.1: {} + ip-address@10.7.1: {} ipaddr.js@1.9.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7a27019d..665446bc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,9 +2,10 @@ packages: - "packages/*" overrides: - fast-uri: 3.1.7 + "@grpc/grpc-js": 1.14.5 + fast-uri: 3.1.8 hono: 4.13.9 - ip-address: 10.5.1 + ip-address: 10.7.1 "@hono/node-server": ">=1.19.15" qs: 6.16.0 diff --git a/scripts/release-line.mjs b/scripts/release-line.mjs index bf8b787b..4c128b08 100644 --- a/scripts/release-line.mjs +++ b/scripts/release-line.mjs @@ -107,6 +107,14 @@ export const RELEASE_LINES = Object.freeze([ requiredPackageNames: [...A2A_PACKAGE_NAMES, "@agentplat/assessor-typesafe"], allPackagesPublishable: true, }), + Object.freeze({ + catalogPackageCount: 66, + id: "stable1-2", + releaseVersion: "1.2.0", + trustPackageCount: 1, + requiredPackageNames: [...A2A_PACKAGE_NAMES, "@agentplat/assessor-typesafe"], + allPackagesPublishable: true, + }), Object.freeze({ catalogPackageCount: 66, id: "stable1-jev-source", @@ -159,7 +167,7 @@ export async function assertReleaseLine({ ) ?? matchingLines[0]; assert.ok( line, - `Release line requires an explicitly supported package cohort: 29 Alpha 3 packages without ${TRUST_PACKAGE_NAME}, 30 Alpha 4, 33 Alpha 5, 34 Beta 1, 36 Beta 2, 56 Beta 5, 62 Beta 6/Beta 7, 65 Beta 7/Beta 8/stable 1.0.0 packages, or the 66-manifest stable 1.0.0 source cohort with @agentplat/assessor-typesafe unpublished, or the 66-package stable 1.1.0 cohort including optional Jev`, + `Release line requires an explicitly supported package cohort: 29 Alpha 3 packages without ${TRUST_PACKAGE_NAME}, 30 Alpha 4, 33 Alpha 5, 34 Beta 1, 36 Beta 2, 56 Beta 5, 62 Beta 6/Beta 7, 65 Beta 7/Beta 8/stable 1.0.0 packages, or the 66-manifest stable 1.0.0 source cohort with @agentplat/assessor-typesafe unpublished, or the 66-package stable 1.1.0/1.2.0 cohorts including optional Jev`, ); assert.equal( diff --git a/scripts/verify-public-consumer.mjs b/scripts/verify-public-consumer.mjs index 1a0eff9f..66ce7289 100644 --- a/scripts/verify-public-consumer.mjs +++ b/scripts/verify-public-consumer.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import semver from 'semver'; import { execFileSync } from 'node:child_process'; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; @@ -31,10 +32,11 @@ const required = collectInternalClosure(targets, recordsByName); const registryRelease = process.env.AGENTPLAT_PUBLIC_CONSUMER_SOURCE === 'registry'; assert.ok(!purposeGovernance || !registryRelease, 'Purpose governance currently verifies local tarballs, not an unpublished registry surface'); -assert.ok(!actionControl || !registryRelease, 'Action-control additions currently require local prepared tarballs'); const registryVersion = JSON.parse( await readFile(path.join(root, 'package.json'), 'utf8'), ).version; +assert.ok(!actionControl || !registryRelease || semver.gte(registryVersion, '1.2.0'), + 'Registry action-control consumption requires published 1.2.0 or newer'); const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), 'agentplat-public-consumer-')); const suppliedTarballRoot = process.env.AGENTPLAT_PREPACKED_TARBALL_DIRECTORY; const tarballRoot = suppliedTarballRoot diff --git a/tests/release-line.test.mjs b/tests/release-line.test.mjs index f3b0f860..03383464 100644 --- a/tests/release-line.test.mjs +++ b/tests/release-line.test.mjs @@ -263,6 +263,20 @@ test("Stable 1.1 requires 66 publishable packages including optional Jev and exa await assert.rejects(()=>assertReleaseLine({root:good,catalog,rootManifest:{version:"1.0.0"}})); }); +test("Stable 1.2 preserves the 66-package cohort and rejects mixed or unpublished members", async (t) => { + const line = RELEASE_LINES.find(x => x.id === "stable1-2"); + const good = await createReleaseLineFixture({ line }); + const mixed = await createReleaseLineFixture({ line, packageVersion: "1.1.0" }); + const missing = await createReleaseLineFixture({ line, includeRequired: false }); + t.after(() => Promise.all([good, mixed, missing].map(root => rm(root, { force: true, recursive: true })))); + assert.equal(await assertReleaseLine({ root: good }), true); + await assert.rejects(() => assertReleaseLine({ root: mixed })); + await assert.rejects(() => assertReleaseLine({ root: missing })); + const catalog = JSON.parse(await readFile(path.join(good, "config/public-packages.json"), "utf8")); + catalog.packages[0].publish = false; + await assert.rejects(() => assertReleaseLine({ root: good, catalog })); +}); + async function createReleaseLineFixture({ duplicateTrust = false, includeRequired = true, From 6991b4988de8d41e42ff3b7a2393e4f90e30efe6 Mon Sep 17 00:00:00 2001 From: grishencorp Date: Wed, 30 Sep 2026 19:40:25 -0300 Subject: [PATCH 3/4] Align standalone action documentation with 1.2.0 adoption --- docs/action-control/README.md | 3 +- docs/action-control/integration.md | 4 +-- .../collective-control-postgres/README.md | 2 +- packages/inference-control/README.md | 30 +++++++++---------- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/docs/action-control/README.md b/docs/action-control/README.md index 152bb94c..4dfd49d1 100644 --- a/docs/action-control/README.md +++ b/docs/action-control/README.md @@ -1,7 +1,8 @@ # Standalone governed external actions **Status:** additive, opt-in source profile; qualification passed in an isolated -checkout on 2026-09-30. Not published to npm. Operational obligations and release +checkout on 2026-09-30. Introduced by the coordinated 1.2.0 release; consult the release record for +publication status. Operational obligations and release steps remain explicit; no production-scale or universal external guarantee is claimed. This profile supports hosts such as The Agent Control without requiring Agent Rooms. diff --git a/docs/action-control/integration.md b/docs/action-control/integration.md index 0f89feac..da3350c5 100644 --- a/docs/action-control/integration.md +++ b/docs/action-control/integration.md @@ -1,7 +1,7 @@ # Integrating standalone governed actions -The optional source additions are not yet on npm. Install a coordinated published -version when available. No Agent Rooms, Agent Mesh or remote ACL service is required. +The optional additions require the coordinated 1.2.0 release. Consult its +release record and install only after distribution is verified. No Agent Rooms, Agent Mesh or remote ACL service is required. ## Composition and owners diff --git a/packages/collective-control-postgres/README.md b/packages/collective-control-postgres/README.md index 75955faa..2e54d19f 100644 --- a/packages/collective-control-postgres/README.md +++ b/packages/collective-control-postgres/README.md @@ -25,7 +25,7 @@ Migration rollback is destructive and requires the exact confirmation token. Before rollback, `getCollectiveRollbackReadinessV1` must report no active work, reserved/dispatching permits, active grants or indeterminate effects. -## Optional standalone action approvals (unpublished source) +## Optional standalone action approvals (1.2.0) `./action-approvals` provides a tenant-scoped approval repository and a separately invoked migration. Existing migrations and grant repositories retain their behavior. diff --git a/packages/inference-control/README.md b/packages/inference-control/README.md index 1609316d..39dceb7e 100644 --- a/packages/inference-control/README.md +++ b/packages/inference-control/README.md @@ -1,23 +1,22 @@ # @agentplat/inference-control Opt-in, provider-neutral control boundaries for inference, released output, -external actions and outbound messages. Alpha 4 is a developer preview. +external actions and outbound messages. Standalone governed-action APIs are introduced in the coordinated 1.2.0 release. -## Installation (developer preview) +## Installation -Install the coordinated preview explicitly: +Install the coordinated version after its distribution is verified: ```sh -npm install @agentplat/inference-control@next +npm install @agentplat/inference-control@1.2.0 ``` -Keep all `@agentplat/*` packages on the same release version. npm's default -`latest` tag can point to an older preview. See the -[release channels](https://github.com/Agentplat/agentplat/blob/main/docs/release-channels.md) -for distribution status and version selection. +Keep the AgentPlat packages you use on the same coordinated version. See the +[1.2.0 release record](https://github.com/Agentplat/agentplat/blob/main/docs/releases/1.2.0-preparation.md) +for current preparation/distribution status before installing. ```sh -pnpm add @agentplat/inference-control@next +pnpm add @agentplat/inference-control@1.2.0 ``` ## Entry points @@ -503,7 +502,7 @@ security properties. The package exposes no scheduler or global agent graph. See [ADR 0042](../../docs/adr/0042-collective-capability-closure.md) and the [architecture and threat model](../../docs/security/collective-capability-closure-v1.md). -## Preparing action grants (additive source API) +## Preparing action grants (additive API) `createActionGrantV1` from `@agentplat/inference-control/tools` prepares an immutable V1 grant from a scope, binding, input, explicit assessment references, idempotency @@ -514,17 +513,16 @@ authenticate a caller, evaluate policy, approve an action or issue the grant. Trusted hosts must resolve identity and authorization, supply verified assessment references, then call `issueActionGrantV1` with their existing repository. The gateway still applies its current authority, assessment and execution checks. Manual grant -construction and existing APIs retain their behavior. This addition is source-only -until included in a published coordinated release; it is not present in npm 1.1.0. +construction and existing APIs retain their behavior. This addition requires the coordinated 1.2.0 release; it is not present in npm 1.1.0. -The new opt-in `./action-approvals` source entry point provides exact-target +The new opt-in `./action-approvals` entry point provides exact-target approval evidence and an assessment wrapper; see [standalone action control](../../docs/action-control/README.md). -It is not part of published npm 1.1.0. The composed profile remains in progress. +It requires 1.2.0. The composition and its bounded qualification are documented in that guide. -The opt-in `./action-admission` source entry point composes transactional resource +The opt-in `./action-admission` entry point composes transactional resource reservations and agent/connector/organization revocation fences with the existing ActionGateway dispatcher. See the standalone action-control guide for trusted-host -requirements, accounting semantics and remaining qualification. +requirements, accounting semantics and qualification boundaries. `./action-effects` offers an explicit conditional-execution adapter contract for external systems that atomically enforce reviewed resource preconditions and From faa0ff5114ff38769056b8bbc1035c220028fd5e Mon Sep 17 00:00:00 2001 From: grishencorp Date: Wed, 30 Sep 2026 19:41:48 -0300 Subject: [PATCH 4/4] Document coordinated PostgreSQL control installation --- packages/collective-control-postgres/README.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/collective-control-postgres/README.md b/packages/collective-control-postgres/README.md index 2e54d19f..00d42cef 100644 --- a/packages/collective-control-postgres/README.md +++ b/packages/collective-control-postgres/README.md @@ -3,18 +3,17 @@ Explicit PostgreSQL durability for `@agentplat/collective-control` authority, execution, Action Grant and evidence repositories. -## Installation (developer preview) +## Installation -Install the coordinated preview explicitly: +Install the coordinated version after its distribution is verified: ```sh -npm install @agentplat/collective-control-postgres@next +npm install @agentplat/collective-control-postgres@1.2.0 ``` -Keep all `@agentplat/*` packages on the same release version. npm's default -`latest` tag can point to an older preview. See the -[release channels](https://github.com/Agentplat/agentplat/blob/main/docs/release-channels.md) -for distribution status and version selection. +Keep the AgentPlat packages you use on the same coordinated version. See the +[1.2.0 release record](https://github.com/Agentplat/agentplat/blob/main/docs/releases/1.2.0-preparation.md) +for current preparation/distribution status before installing. The adapter owns no policy transitions. Applications run the additive migration explicitly, initialize scoped repositories, and use the portable reducers for