Skip to content

Define defer/subscribe for DEFER resolution - #496

Open
President wants to merge 2 commits into
GenAI-Security-Project:integrationfrom
disciplinedware:spec/14-defer-subscribe
Open

President wants to merge 2 commits into
GenAI-Security-Project:integrationfrom
disciplinedware:spec/14-defer-subscribe

Conversation

@President

Copy link
Copy Markdown

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/subscribe request with payload.deferred_request_id set to the request_id of the deferred step. For a pending deferral, the Guardian first sends a defer/pending notification. When the deferral resolves, the Guardian answers the subscription with a signed response that carries a new defer_resolution object. The Guardian also signs the resolution record, writes it as an audit event, and appends one ContextEntry to 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 A2A SubscribeToTask.

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."

  • The existing request_id of the deferred step is the equivalent. The subscription carries it in payload.deferred_request_id, and the resolution carries it in defer_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."

  • decision holds the Guardian's final decision (§6.6, Decision at resolution).
  • defer_resolution.resolution_method names what resolved the deferral (§6.6, defer_resolution).
  • defer_resolution.resolved_at holds the time of the resolution.
  • defer_resolution.deferred_request_id holds the original request_id.
  • defer_resolution.deferred_metadata copies params.metadata of the deferred step's request, with the identity that request carried.
  • The Guardian signs the resolution record when the deferral resolves and writes it as an audit event, even when no agent subscribes (§6.6, Audit; §10). Each response that carries the resolution is signed too (§6.6, Messages).

3. "Timeout and agent restart both resolve to DENY, and the record says which."

  • A deferral that is still pending at the Guardian's deadline resolves to deny with resolution_method: timeout (§6.6, Timeout).
  • Not met for an agent restart (§6.6, Session end). ACS has no message that tells the Guardian about an agent restart, so no resolution says "restart". A restarted agent starts a new session, and the new session cannot subscribe to a deferral of the old session. A deferral of the old session ends in one of three ways:
    • deny with session_end, when the Guardian receives steps/sessionEnd for the old session or its stdio channel closes;
    • deny with timeout, when nothing resolves the deferral before the Guardian's deadline;
    • an approval or additional context, when it comes before both.

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.

  • This is an editorial correction (typo, grammar, link, formatting) with no change in
    meaning, so it needs no issue

Base branch

  • integration, because this touches the specification, schemas, a reference
    implementation, an adapter, tests, or CI
  • main, because every changed path is on the documentation lane allowlist in
    CONTRIBUTING.md

Type of change

  • Specification change (schema, hooks, events, AgBOM)
  • Reference implementation or adapter
  • Documentation
  • Tooling or CI
  • Governance (licensing, security policy, contributor docs)

Specification changes

  • I opened a Discussion before this PR
  • Schema changes validate against the JSON Schema spec
  • I described the impact on downstream implementers below

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.

File Change
docs/spec/instrument/specification.md §6.6 defines the method and includes a sequence diagram. §3 and §15 name defer/subscribe as the one exception to "no streaming in v0.1". §6, §6.1 and §6.4 point to §6.6. §8.1 says what request_hash covers 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.json This new schema defines the defer/subscribe payload and the defer/pending notification.
specification/v0.1.0/defer-resolution.json This new schema defines the defer_resolution object.
specification/v0.1.0/response-envelope.json The schema adds defer_resolution and refuses the resolutions that §6.6 forbids.
specification/v0.1.0/defer-details.json timeout_decision allows only deny, so an expired DEFER always fails closed.
docs/spec/trace/events.md, trace/otel-mapping.json, trace/ocsf-mapping.json The acs.decision event 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.json The request and handshake schemas admit defer/*. context-entry.json describes the resolution entry. acs_schema.json lists both new schemas.
docs/spec/instrument/hooks.md, docs/spec/conformance.md, docs/acs.md The Hooks page describes the method. The conformance page adds three ACS-Core obligations for DEFER resolution. The ACS overview lists defer/subscribe.
tests/test_defer_subscribe.py This new test checks valid messages and invalid resolutions against the schemas.
README.md, CONTRIBUTING.md, docs/topics/faq.md, two workflow comments, two tools/ messages These files update the counts to 46 schemas and 23 methods.

DEFERRAL_NOT_FOUND takes the next free code, -32012. Discussion #257 proposes -32008, PR #294 uses -32009, and the TypeScript Guardian returns -32010, -32011 and -32020.

Points I would like reviewers to check:

This PR does not implement a Guardian. It leaves out numeric deferral limits and order (#456), deferred toolCallResult handling (#304), approval scope (#51), and how the approver's answer or required_context reaches the Guardian (#175).

Breaking for implementers? defer_details.timeout_decision loses its ask value. A Guardian that sends timeout_decision: ask must send deny or omit the field. ACS-Core adds three obligations. A Guardian that returns DEFER MUST implement defer/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 implement defer/subscribe, in the conditional form #21 set for Wrapped MCP and subagentStart. For defer/subscribe on HTTP, the Guardian sends Server-Sent Events and the Observed Agent reads them.

I tested this

  • I synced my branch with the base branch before opening this
  • uv run pytest -v passes on my machine
  • uv run mkdocs build --strict passes on my machine

uv run pytest -v passes 1224 tests. uv run mkdocs build --strict reports no warnings. The local schema publish check places both new schemas at their $id paths, and every $ref resolves. No implementation of defer/subscribe exists yet, so no end-to-end run has happened.

Checklist

  • Commits are signed off with git commit -s (required by the DCO)
  • Prose follows STYLE.md
  • No secrets, tokens, or internal URLs in the diff

Security

  • This change has no security impact

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/pending has no signature, and a forged notification carries no decision, so it cannot authorize the deferred step.

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>
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

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 drk-ram left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_decision allows only deny; a resolution with timeout or session_end must be deny; an approver's deny must stay deny; the final decision can never be defer; 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 no on_decision_failure posture, proceed or deny, 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 timeout resolution normally reaches the agent first. The "resolve early enough" SHOULD is the right way to close that margin.
  • request_id is format: uuid in request-envelope.json, so deferred_request_id as a UUID matches what it points at. deferred_metadata resolves to request-envelope.json#/$defs/Metadata, and approval.approver resolves to ask-details.json#/properties/approver.
  • Counts and cross-references: 19 steps/* hooks plus agbom/snapshot, agbom/changed, system/ping and defer/subscribe is 23, and the Hooks page, the FAQ and the diagram agree. 44 schemas becomes 46, and publish_schemas.py published 46. §3 and §15 name the one streaming exception, -32012 is added to §17.1, and the request_hash rule for the resolution entry is stated in §6.6, §8.1, context-entry.json and 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.json alert_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 in test_closing_choice.py, test_fetch_roadmap.py, test_overrides.py, test_pr_intake_workflow.py and test_publish_schemas.py. Those five files give the same 10 failed and 20 errors on bba1b84 itself, 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)

  1. chain_hash is required in prose and not in the schema. §6.6 says the response that carries the resolution MUST include chain_hash, and the test docstring says the tests hold the rules a schema can express. This one can be expressed: a defer_resolution result with no chain_hash validates 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.
  2. approval is required for human_approval and not forbidden elsewhere. The prose says human_approval "also carries approval", which reads as only then. An additional_context resolution that carries an approver's allow validates, and timeout plus an allow approval is rejected only because the reason_codes rule happens to fire. Either add the else branch (approval absent unless human_approval) or say in §6.6 that other methods may carry it.
  3. The resolution ContextEntry reuses the DEFER step's step_id. §6.6 sets step_id to the deferred_request_id, which is also the step_id of the entry written when the DEFER was returned. Elsewhere the spec addresses entries by step_id alone: entries_to_compact in preCompact is a list of step_ids, earliest_step_id_by_origin is a step_id, and context-entry.json still says step_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 a defer_resolution entry is not a valid entries_to_compact target on its own.
  4. #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.py reads every ## section of hooks.md that has a Schema: link and a **Payload:** row, and hard-codes 19. With this PR's defer/subscribe section merged in, I get 20 rows and defer/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.

Lrd0036 added a commit to Lrd0036/agent-control-standard that referenced this pull request Nov 4, 2026
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>
@acs-board-reconciler acs-board-reconciler Bot moved this from Needs triage to In progress in ACS Project Tracker Nov 5, 2026
| `-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`. |

@Lrd0036 Lrd0036 Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status:needs-triage Not yet triaged. Applied by the issue forms

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

3 participants