Repository navigation
Conversation
A DEFER had no wire method that delivers its resolution, so two conformant implementations could not interoperate on a deferred step. Add Specification 6.6. The Observed Agent subscribes with defer/subscribe, naming the deferred step by the request_id of its own request. While the deferral is pending, the Guardian sends a defer/pending notification at once. When the deferral resolves, the Guardian sends the response, a signed AcsResult with defer_resolution. When the deferral resolves, the Guardian signs the resolution record and writes it as an audit event, whether or not an agent subscribes. It also appends one ContextEntry to the session's audit chain. On HTTP the messages travel as Server-Sent Events. On stdio they share the session channel. A Guardian that returns DEFER implements the method, in the conditional form ACS-Core uses for Wrapped MCP and subagentStart. An approver's deny resolves to deny. After an approver's allow, the final decision may be another decision, with reason_codes naming the cause. A timeout and a session end resolve to deny, and the record says which. A failed subscription is not a decision failure, so an expired DEFER fails closed. An agent restart starts a new session. Narrow timeout_decision to deny, so an expired DEFER never becomes an ASK. Map the resolution in Trace: its decision event belongs to the deferred step, and the handshake timeout does not apply to it. Register DEFERRAL_NOT_FOUND as -32012, add the defer/* namespace, the two schemas and the rules a resolution must meet, with a test. Refs GenAI-Security-Project#14 Claude-Session: https://claude.ai/code/session_01NFeGFL8hUicqGhyDUkNZZ1 Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
|
Thanks for this. It is queued rather than ignored. A lead has not reviewed this pull request yet. A lead will read it when they have time, and we can't promise a date. Nothing here is rejected. If a check below fails, please fix it. A pull request that waits a long time may be closed, and you can reopen it later. |
The agt job compiles response-envelope.json with Ajv in strict mode. Strict mode refuses a required or minItems keyword whose subschema names no type, so the schema registry failed to build and every response check in the TypeScript Guardian reported the response as unchecked. Give each nested defer_resolution and approval condition its object type and the reason_codes condition its array type. The rules mean the same. Refs GenAI-Security-Project#14 Claude-Session: https://claude.ai/code/session_01NFeGFL8hUicqGhyDUkNZZ1 Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
drk-ram
left a comment
There was a problem hiding this comment.
Reviewed at head 034f52a54161c67edd32ac76057278af4c92f2b7. Approving, with four notes. The first is a concrete collision with #498 that should be settled before either one merges.
What I checked against the issue and the neighbouring spec text
- The design holds together. The Guardian keeps the deferred request, the approver answers the Guardian, and the Observed Agent learns the outcome through one signed response that carries
defer_resolution. The sequence diagram matches the normative paragraphs. - Every limit that makes this fail closed is there:
timeout_decisionallows onlydeny; a resolution withtimeoutorsession_endmust bedeny; an approver'sdenymust staydeny; the final decision can never bedefer; and the Observed Agent denies the step at its own local deadline and ignores a late resolution. A failed subscription is expressly not a decision failure, so noon_decision_failureposture,proceedordeny, can reopen a DEFER. That also makes §6.6 independent of the default flip decided on #32. - The Guardian deadline runs from when it sent the DEFER, and the agent's runs from when it received it. The agent's clock starts later, so the Guardian's
timeoutresolution normally reaches the agent first. The "resolve early enough" SHOULD is the right way to close that margin. request_idisformat: uuidinrequest-envelope.json, sodeferred_request_idas a UUID matches what it points at.deferred_metadataresolves torequest-envelope.json#/$defs/Metadata, andapproval.approverresolves toask-details.json#/properties/approver.- Counts and cross-references: 19
steps/*hooks plusagbom/snapshot,agbom/changed,system/pinganddefer/subscribeis 23, and the Hooks page, the FAQ and the diagram agree. 44 schemas becomes 46, andpublish_schemas.pypublished 46. §3 and §15 name the one streaming exception,-32012is added to §17.1, and therequest_hashrule for the resolution entry is stated in §6.6, §8.1,context-entry.jsonand the ACS-Audit paragraph. - The one sentence that disappears is the DEFER-falls-back-to-ASK line in the OCSF and OTel text, and it goes in every place it lived (
events.md,ocsf-mapping.jsonalert_mapping). I found none left.
Schema probes. I built 15 variants of the example resolution against response-envelope.json#/$defs/AcsResult. Accepted as the spec says: a plain approver allow, an approver deny with a final deny and no reason_codes, an additional_context allow with no approval, timeout and session_end denies. Rejected as the spec says: a missing deferred_metadata, an empty one, a non-UUID deferred_request_id, an unknown method such as restart, modify with an empty reason_codes, and an ask without ask_details. Two combinations validate that I think the text forbids; they are notes 1 and 2.
Validation at this head (Windows host, uv, branch is two commits on integration at bba1b84)
uv run pytest -q: 1203 passed, 1 skipped, 10 failed, 20 errors. The 30 are intest_closing_choice.py,test_fetch_roadmap.py,test_overrides.py,test_pr_intake_workflow.pyandtest_publish_schemas.py. Those five files give the same 10 failed and 20 errors onbba1b84itself, so none comes from this PR.tests/test_defer_subscribe.py: 18 passed.uv run --no-dev mkdocs build --strict -d _site/docs: exit 0.publish_schemas.py specification _site/schema: 46 schemas published.
Notes (none blocking)
chain_hashis required in prose and not in the schema. §6.6 says the response that carries the resolution MUST includechain_hash, and the test docstring says the tests hold the rules a schema can express. This one can be expressed: adefer_resolutionresult with nochain_hashvalidates today (I removed it from the example and it passed). Adding{ "if": { "required": ["defer_resolution"] }, "then": { "required": ["chain_hash"] } }and one mutation case closes it.approvalis required forhuman_approvaland not forbidden elsewhere. The prose sayshuman_approval"also carriesapproval", which reads as only then. Anadditional_contextresolution that carries an approver'sallowvalidates, andtimeoutplus anallowapproval is rejected only because thereason_codesrule happens to fire. Either add theelsebranch (approvalabsent unlesshuman_approval) or say in §6.6 that other methods may carry it.- The resolution ContextEntry reuses the DEFER step's
step_id. §6.6 setsstep_idto thedeferred_request_id, which is also thestep_idof the entry written when the DEFER was returned. Elsewhere the spec addresses entries bystep_idalone:entries_to_compactinpreCompactis a list ofstep_ids,earliest_step_id_by_originis astep_id, andcontext-entry.jsonstill saysstep_id"SHOULD equal the request_id of the originating envelope". Please say in §6.6 or §8 that entries are keyed by(step_id, step_type), or that adefer_resolutionentry is not a validentries_to_compacttarget on its own. - #498 and this PR break each other's tests when both merge. They merge without a textual conflict, but #498's
tests/test_hook_payload_summaries.pyreads every##section ofhooks.mdthat has aSchema:link and a**Payload:**row, and hard-codes 19. With this PR'sdefer/subscribesection merged in, I get 20 rows anddefer/subscribe: the payload row names ['request_id'], which defer-subscribe.json does not define, so two tests fail. I am raising it on #498 as well, with the small change that would fix it there. If this lands first, you may want to know it is coming.
On criterion 3 of #14: you are right to leave the restart half unmet and to say so rather than invent a message the protocol does not have. I would keep Refs #14 so the maintainers decide, as written. Approving the content at this head.
The payload guard read every section with a schema link and a payload row, so GenAI-Security-Project#496's defer/subscribe section would make two of its tests fail: it counts 20 rows, and request_id is not in defer-subscribe.json. The guard now reads only the sections with bare names, which are the 19 steps/* hooks, and a new test adds a defer/subscribe section to the page and checks that it is skipped. The agentTrigger row listed trigger_source as required. agent-trigger.json requires only trigger_type, so the row now marks trigger_source optional. Both come from drk-ram's review of GenAI-Security-Project#498. Signed-off-by: Lrd0036 <lrd0036@auburn.edu>
| | `-32005` | `REPLAY_DETECTED` | Duplicate `request_id` or `nonce` within the session (§10.3). | Regenerate `request_id` (and `nonce`) and retry. | | ||
| | `-32006` | `TIMESTAMP_OUT_OF_WINDOW` | `timestamp` falls outside the negotiated skew window (§10.3); `data.skew_window_ms` carries the window. | Correct clock drift and retry within the window. | | ||
| | `-32007` | `CHAIN_MISMATCH` | The client's `chain_hash` does not match the Guardian's computed head (§8.6). Also exposable as a deny `reason_code: "chain_mismatch"` when the Guardian can still return a decision. | Re-fetch session state; a persistent mismatch is an integrity event, not a transient error. | | ||
| | `-32012` | `DEFERRAL_NOT_FOUND` | The `deferred_request_id` of a `defer/subscribe` does not identify a deferred step of this session (§6.6). | Treat the deferred step as `deny` (§6.6); do not subscribe again for that `deferred_request_id`. | |
There was a problem hiding this comment.
FAIL: test_every_spec_error_code_has_its_own_cause
-32012 : an audit record should name the §17 code, not the generic fallback
Easy fix. only one line GUARDIAN_ERROR_CAUSE, after -32007:
-32012: "deferral_not_found_response",
GUARDIAN_ERROR_CAUSE lives in adapters/src/acs/core/_errors.py.
What changed
A Guardian can answer a step with DEFER. Before this PR, ACS defined no message that delivers the final decision to the Observed Agent, and no audit chain entry for that decision. This PR adds §6.6 with one request method,
defer/subscribe, and one notification,defer/pending.The Observed Agent sends a new
defer/subscriberequest withpayload.deferred_request_idset to therequest_idof the deferred step. For a pending deferral, the Guardian first sends adefer/pendingnotification. When the deferral resolves, the Guardian answers the subscription with a signed response that carries a newdefer_resolutionobject. The Guardian also signs the resolution record, writes it as an audit event, and appends oneContextEntryto the audit chain.On HTTP, the Guardian sends the notification and the response as Server-Sent Events. On stdio, the messages use the session channel. The design follows MCP 2026-07-28
subscriptions/listen: one request, then a stream that ends with a JSON-RPC response. The rule "send the current state first" comes from A2ASubscribeToTask.Issue #14 lists three acceptance criteria. Each criterion is quoted below, with a checkbox for each of its parts.
1. "The DEFER answer carries a
deferred_request_id(or equivalent) that links the resolution back to the original request."request_idof the deferred step is the equivalent. The subscription carries it inpayload.deferred_request_id, and the resolution carries it indefer_resolution.deferred_request_id(§6.6, Identifier). The DEFER answer needs no new field.2. "The resolution is a signed record carrying the final decision, the resolution method, the resolution time, the original
request_id, and the identity the original request carried."decisionholds the Guardian's final decision (§6.6, Decision at resolution).defer_resolution.resolution_methodnames what resolved the deferral (§6.6,defer_resolution).defer_resolution.resolved_atholds the time of the resolution.defer_resolution.deferred_request_idholds the originalrequest_id.defer_resolution.deferred_metadatacopiesparams.metadataof the deferred step's request, with the identity that request carried.3. "Timeout and agent restart both resolve to DENY, and the record says which."
denywithresolution_method: timeout(§6.6, Timeout).denywithsession_end, when the Guardian receivessteps/sessionEndfor the old session or its stdio channel closes;denywithtimeout, when nothing resolves the deferral before the Guardian's deadline;Which issue does this implement
Refs #14. One part of criterion 3 is not met (above), so the maintainers decide whether this PR closes the issue.
meaning, so it needs no issue
Base branch
integration, because this touches the specification, schemas, a referenceimplementation, an adapter, tests, or CI
main, because every changed path is on the documentation lane allowlist inCONTRIBUTING.md
Type of change
Specification changes
The design was discussed in #14, where the issue was accepted. This PR asks the maintainers to accept that thread in place of a separate Discussion.
docs/spec/instrument/specification.mddefer/subscribeas the one exception to "no streaming in v0.1". §6, §6.1 and §6.4 point to §6.6. §8.1 says whatrequest_hashcovers for the resolution entry. §10 adds the signed input of the resolution record. §17.1 adds-32012 DEFERRAL_NOT_FOUND.specification/v0.1.0/hooks/defer-subscribe.jsondefer/subscribepayload and thedefer/pendingnotification.specification/v0.1.0/defer-resolution.jsondefer_resolutionobject.specification/v0.1.0/response-envelope.jsondefer_resolutionand refuses the resolutions that §6.6 forbids.specification/v0.1.0/defer-details.jsontimeout_decisionallows onlydeny, so an expired DEFER always fails closed.docs/spec/trace/events.md,trace/otel-mapping.json,trace/ocsf-mapping.jsonacs.decisionevent of a resolution goes on the span of the deferred step. A resolution uses the deadlines of §6.6, not the decision timeout of the handshake. The sentence that a DEFER can fall back to ASK is gone.request-envelope.json,handshake.json,context-entry.json,acs_schema.jsondefer/*.context-entry.jsondescribes the resolution entry.acs_schema.jsonlists both new schemas.docs/spec/instrument/hooks.md,docs/spec/conformance.md,docs/acs.mddefer/subscribe.tests/test_defer_subscribe.pyREADME.md,CONTRIBUTING.md,docs/topics/faq.md, two workflow comments, twotools/messagesDEFERRAL_NOT_FOUNDtakes the next free code,-32012. Discussion #257 proposes-32008, PR #294 uses-32009, and the TypeScript Guardian returns-32010,-32011and-32020.Points I would like reviewers to check:
allow, other policy can still requiredeny,modifyorask, and the response then names the cause inreason_codes. An approver'sdenyalways staysdeny. Re-checking the step at resolution is recommended, not required.defer_details.timeout_decisionloses itsaskvalue. The TypeScript host adapter already answers an expired DEFER withtimeout_decision: askwithdenyand the reason codedefer_ask_unaskable.v0.1.0. If that rule puts this change in v0.2.0, this PR moves to the v0.2.0 schema namespace.This PR does not implement a Guardian. It leaves out numeric deferral limits and order (#456), deferred
toolCallResulthandling (#304), approval scope (#51), and how the approver's answer orrequired_contextreaches the Guardian (#175).Breaking for implementers?
defer_details.timeout_decisionloses itsaskvalue. A Guardian that sendstimeout_decision: askmust senddenyor omit the field. ACS-Core adds three obligations. A Guardian that returns DEFER MUST implementdefer/subscribe. That Guardian MUST record each resolution as an audit event with a signature over the resolution record. An Observed Agent that suspends a step on DEFER MUST subscribe. A Guardian that never returns DEFER need not implementdefer/subscribe, in the conditional form #21 set for Wrapped MCP andsubagentStart. Fordefer/subscribeon HTTP, the Guardian sends Server-Sent Events and the Observed Agent reads them.I tested this
uv run pytest -vpasses on my machineuv run mkdocs build --strictpasses on my machineuv run pytest -vpasses 1224 tests.uv run mkdocs build --strictreports no warnings. The local schema publish check places both new schemas at their$idpaths, and every$refresolves. No implementation ofdefer/subscribeexists yet, so no end-to-end run has happened.Checklist
git commit -s(required by the DCO)Security
The Guardian signs each resolution record and each response that carries a resolution (§10). A verifier without the signing key cannot change them unnoticed. Under the HMAC baseline, both parties hold the key, so a signature does not prove which party signed. ACS-Crypto signatures prove that the Guardian signed.
defer/pendinghas no signature, and a forged notification carries no decision, so it cannot authorize the deferred step.