Skip to content

SEP-2817: AI Invocation Audit Context in Request _meta - #2817

Open
hangum wants to merge 9 commits into
modelcontextprotocol:mainfrom
hangum:sep-ai-invocation-audit-context
Open

hangum wants to merge 9 commits into
modelcontextprotocol:mainfrom
hangum:sep-ai-invocation-audit-context

Conversation

@hangum

@hangum hangum commented May 29, 2026

Copy link
Copy Markdown

This PR adds a new Standards Track SEP (Status: Draft, seeking a sponsor): AI Invocation Audit Context in Request _meta.

It standardizes one optional reserved _meta key — io.modelcontextprotocol/aiInvocation — carrying optional, client-asserted input-audit context for AI-initiated MCP requests:

  • invocationReason — why the AI/client made this call
  • model — which model produced the invocation
  • userIntent — the user-level intent that caused the work (when safe to provide)
  • turnId — groups MCP requests from the same user turn (servers MAY echo only turnId in response _meta)

All fields are optional and explicitly not authorization evidence. The proposal is deliberately minimal; server-side decision records, stable tool-call identity, agent/session correlation, and taxonomies are left to follow-up SEPs.

Operationalizes Discussion #2704: #2704

Prior art reconciled in the SEP: SEP-2787 (tool-call attestation), SEP-414 (OTel trace context), SEP-1788 / #775 / #2758 (_meta key reservation), SEP-2643 (structured authorization denials), SEP-2061 (action security metadata), SEP-2448 / SEP-2028 (telemetry).

AI assistance disclosure

This SEP was prepared with AI assistance for structure, wording, and review. The proposal direction, implementation experience, and final technical judgment were reviewed and edited by the author.

hangum and others added 2 commits May 30, 2026 01:43
Standards Track, Draft. Defines one optional reserved _meta key
(io.modelcontextprotocol/aiInvocation) carrying client-asserted
input-audit context (invocationReason, model, userIntent, turnId) for
AI-initiated MCP requests. Operationalizes discussion modelcontextprotocol#2704.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hangum hangum changed the title SEP-0000: AI Invocation Audit Context in Request _meta SEP-2817: AI Invocation Audit Context in Request _meta May 29, 2026
@hangum
hangum requested review from a team as code owners May 29, 2026 16:52
@rpelevin

Copy link
Copy Markdown

Thanks @hangum — this looks like the right SEP-0 boundary to me.

From an implementer perspective, the valuable split is clear:

  • request _meta carries client-asserted input-audit context: why this call, which model, optional user intent, and turn correlation
  • server-side decision records stay out of scope until a follow-up SEP can define stable tool-call identity, approval lifecycle, policy outcome, and decision-record semantics

The latest clarifications around per-emitted-request invocationReason, provider-canonical model.name, multi-model routing, and turnId not being ordering/idempotency/tool-call identity make the shape much easier to implement consistently.

I would keep the security boundary sharp: these fields can help explain how an invocation was produced, but they should not be treated as evidence that the invocation was authorized.

That preserves a clean path for a follow-up decision-record SEP without overloading this first input-audit layer.

@hangum

hangum commented May 29, 2026

Copy link
Copy Markdown
Author

Thanks @rpelevin — that matches the intended boundary.

I agree that the most important part is keeping this layer as client-asserted input-audit metadata only. It can explain how an invocation was produced, but it must not be treated as authorization evidence.

That should leave a clean path for a follow-up decision-record SEP to define the server-authoritative layer separately: stable tool-call identity, approval/policy outcome, and execution/decision semantics.

@vaaraio

vaaraio commented May 29, 2026

Copy link
Copy Markdown

Implementer note from the SEP-2787 side. We shipped a proxy that emits a signed execution receipt paired with 2787 attestation per tools/call, and posted proposed-shape test vectors in #2789.

The "explicitly not authorization evidence" line is the right call, and it's worth saying why. invocationReason, userIntent and turnId are client-asserted, so a host can record them but can't prove them. They describe intent, not what executed.

The other half is a server- or proxy-side signed record of what actually ran: the resolved tool, an arguments digest, a result digest, and the 2787 attestation, bound to the same turn. Neither half stands alone as evidence. Client-asserted context with no signed execution record is unverifiable. A signed execution record with no intent context is hard to interpret. Bound together they give an auditable trail from why the model made the call to what the server actually did, which is the gap your follow-up bullet on server-side decision records points at.

Concretely, the turnId echo you already allow in response _meta is a clean correlation hook. If a server, or a proxy in front of it, echoes turnId alongside a signed receipt reference, a verifier can line up the client-asserted aiInvocation block with the signed server-side record for the same turn without any new message shape.

If the receipt field layout we settled on is useful input for the stable-tool-call-identity follow-up, I can post it.

@AgentGymLeader

Copy link
Copy Markdown

+1 to @vaaraio’s framing. Using the turnId echo as a correlation hook between client-asserted aiInvocation context and a server/proxy-side signed execution receipt seems like a clean way to preserve the boundary here: intent context is not authorization evidence, but it can still help a verifier interpret the server-side record.

If the SEP-2787 receipt field layout is ready to share, I think posting it here would be useful input for the follow-up stable-tool-call-identity / decision-record discussion.

@hangum

hangum commented May 30, 2026

Copy link
Copy Markdown
Author

Thanks @vaaraio and @AgentGymLeader — this framing matches the intended boundary.

I agree that aiInvocation context by itself is not evidence of what executed. It explains the client/model-side intent, while a server/proxy-side signed receipt can prove what actually ran.

Using turnId echo as a correlation hook between the client-asserted input-audit context and a signed execution receipt seems like a good composition point, without expanding SEP-2817 beyond its current scope.

If you can share the SEP-2787 receipt field layout, that would be useful prior art for the follow-up stable-tool-call-identity / decision-record SEP.

@XuebinMa

Copy link
Copy Markdown

As committed in #2704, here's how SEP-2817's _meta["io.modelcontextprotocol/aiInvocation"] maps onto a non-MCP-native runtime's Guard → AuditRecord → ExecutionProof path (agent-guard), plus a working sketch. Offered as a cross-implementation data point, not a conformance claim.

The load-bearing point first: these fields enter the audit stage, never the policy stage.

agent-guard's pipeline is Check → Filter → Audit → Sandbox. Policy evaluation (Check, via an evalexpr DSL) reads only deterministic context — tool, payload, trust_level, matched rule. The aiInvocation.* fields are ingested at the boundary and flow only into the Audit stage. So "invocationReason/userIntent are not authorization evidence" isn't a doc promise here — it's enforced by which pipeline stage can see them. A policy condition cannot reference aiInvocation because it never reaches the evaluator.

Field mapping

SEP-2817 _meta.aiInvocation Pipeline stage Lands in (agent-guard) Notes
invocationReason {kind?, text} Audit (not Check) AuditEvent.details.invocationReason text carried as the stable fallback; unrecognized kind stored verbatim, never rejected
model {name, provider?, version?} Audit AuditEvent.details.model model that produced this request, distinct from host/client identity
userIntent {text?, hash?, redacted?} Audit AuditEvent.details.userIntent stored as-asserted; redacted:true w/o text recorded as "intent withheld"; never read by Check
turnId Audit + correlation first-class correlation key on AuditEvent, echoed in response _meta the missing middle granularity — finer than Context.session_id, coarser than request_id

agent-guard's existing Context { agent_id, session_id, actor, trust_level } already covers the agent/session correlation raised in the follow-up discussion; turnId slots cleanly between session_id and the per-request request_id. It's the one new correlation field this SEP motivates on the ingestion side.

Where the SEP-0 / follow-up boundary falls out naturally

The signed outcome — ExecutionProof { payload_hash, sandbox_type, exit_code, timestamp, signature } (Ed25519) — is the server-authoritative record, i.e. the follow-up SEP's territory. The input-audit _meta fields stay on the AuditEvent side. turnId is the single field that bridges the two layers: it can be carried alongside the signed ExecutionProof so a tamper-evident outcome can be joined back to the user turn that caused it — while invocationReason/model/userIntent stay strictly client-asserted input audit and never enter the signed outcome. That matches the split drawn in this PR: turnId = correlation in SEP-0; signed decision/outcome identity = follow-up.

Working sketch

Input request _meta:

{
  "io.modelcontextprotocol/aiInvocation": {
    "invocationReason": { "text": "User asked to view employee rows; employees table identified as target." },
    "model": { "name": "example-model" },
    "userIntent": { "text": "Show me 10 employees", "redacted": false },
    "turnId": "opaque-client-generated-id"
  }
}

Resulting audit record (AuditRecord::ToolCall, serde JSON, abbreviated):

{
  "type": "tool_call",
  "timestamp": "2026-05-30T00:00:00Z",
  "request_id": "req-7f3a",
  "session_id": "sess-abc",
  "turn_id": "opaque-client-generated-id",
  "agent_id": "coder-1",
  "tool": "bash",
  "payload_hash": "sha256:…",
  "decision": "allow",
  "policy_version": "0.2.0",
  "matched_rule": "read_only_select",
  "details": {
    "aiInvocation": {
      "invocationReason": { "text": "User asked to view employee rows; …" },
      "model": { "name": "example-model" },
      "userIntent": { "text": "Show me 10 employees", "redacted": false }
    }
  }
}

decision / matched_rule are computed without ever reading details.aiInvocation — the audit context rides alongside the decision, not into it. The only structural addition this maps to is promoting turnId to a first-class correlation field next to session_id; the rest lands in the existing audit details bag today.

Happy to refine this against the SEP as the schema firms up, and to run the implementation pass once it's open for review.

@hangum

hangum commented May 30, 2026

Copy link
Copy Markdown
Author

Thanks @XuebinMa — this is a very useful implementation mapping.

The most important part is that aiInvocation enters only the audit path and is not visible to policy evaluation. That is exactly the security boundary SEP-2817 is trying to preserve: client-asserted context can explain the invocation, but authorization must rely on deterministic server-side context.

The session_id → turnId → request_id layering is also a helpful data point. It shows why turnId belongs in SEP-2817 while stable execution proof / signed outcome identity belongs in the follow-up decision-record layer.

I will treat this as a cross-implementation reference for the PR discussion, without expanding SEP-2817’s scope.

@hangum

hangum commented May 30, 2026

Copy link
Copy Markdown
Author

For another implementation data point, TadpoleDBHub / Tadpole AI CLI currently implements a similar audit shape, but with app-specific arguments and headers because MCP does not yet have a standard _meta location for this context.

Current mapping:

  • user_query: captured as the user's original turn input and persisted in the AI audit path, joined by mcp_request_id.
  • invocation_reason: generated per tool call by the AI CLI tool schema, forwarded as a tool argument, and persisted on the SQL/service audit row as the per-call reason.
  • model: Tadpole AI CLI sends X-MCP-Client-Model when available; the server stores it in the AI audit session as the model that triggered the MCP request.
  • correlation: the server creates/reuses a turn-level request_id; SQL audit rows store executed_sql_resource.request_id, while AI chat rows store ai_session_body.mcp_request_id, so the integrated audit view can join user intent -> MCP tool call -> SQL execution -> events.

For strict audit configurations, TadpoleDBHub can reject calls missing the user intent or per-call invocation reason, but these fields remain audit-completeness inputs. Authorization and approval decisions still rely on server-side user/service/DB policy and execution controls, not on client-asserted rationale.

This is the implementation pressure behind SEP-2817: the context is operationally useful, but without a standard _meta location each server has to encode it as custom tool arguments or transport headers.

@vaaraio

vaaraio commented May 30, 2026

Copy link
Copy Markdown

@hangum here's the layout. It's shipped in v0.42 (vaara.attestation.receipt), with v0 normative vectors and a stdlib-only independent checker in the repo, so the field names below are what actually ships and there are vectors to check them against.

Three blocks plus the signature, mirroring the SEP-2787 trust-surface layout so both envelopes verify with the same canonicalization (RFC 8785 JCS) and the same HS256/ES256/RS256 stack. A 2787 verifier needs no new crypto to check a receipt.

{
  "version": 0,
  "alg": "ES256",
  "backLink": {
    "attestationDigest": "sha256:...",   // over the full 2787 wire envelope, signature included
    "attestationNonce": "..."            // echo of issuerAsserted.nonce, for fast correlation
  },
  "receiptAsserted": {
    "iss": "...", "sub": "...", "iat": "...",
    "nonce": "...", "secretVersion": "...", "alg": "ES256"
  },
  "outcomeDerived": {
    "status": "executed | refused | errored",
    "completedAt": "...",
    "resultCommitment": { }              // optional; absent for a refused call
  },
  "signature": "..."
}

The field that carries the follow-up SEP is backLink. It pins the exact attestation instance two ways: a digest over the attestation's full canonical wire bytes with the signature included, and the nonce echo. So the outcome is cryptographically joined to the request it answers, not joined by a shared id any party could reassert.

No exp, no TTL. A receipt is a durable record, not a capability, and the verifier enforces no expiry. Same split this thread keeps drawing: the attestation can expire, the record of what ran does not.

resultCommitment reuses 2787's argument-commitment shapes (ArgsRef / ArgsProjection) unchanged, since a result commitment is the same structure, a commitment over a JSON value. Full value, projection, or hash-only all work. status is an enum, so a refused call still produces a signed record with no result commitment; a denied tool call is audit evidence too.

@XuebinMa your ExecutionProof { payload_hash, sandbox_type, exit_code, timestamp, signature } is the same category, the server-authoritative signed outcome. The one thing worth lifting into the follow-up SEP is the explicit back-link. Pinning the outcome to a specific signed request attestation, instead of letting it stand alone, is what lets a verifier prove this outcome answers that exact attested call. turnId composes on top: the backLink pins request to outcome cryptographically, turnId carries the human-turn join the nonce can't, so 2817's turnId plus the receipt's backLink give the full chain from user turn to attested request to signed outcome.

Layout, vectors, and the offline verifier (vaara receipt verify, v0.44): docs/execution-receipts.md and tests/vectors/execution_receipt_v0 in vaaraio/vaara.

@hangum

hangum commented May 30, 2026

Copy link
Copy Markdown
Author

Thanks @vaaraio — this is very useful prior art for the follow-up decision-record / execution-receipt SEP.

I agree with the split: SEP-2817 should keep turnId as human/user-turn correlation for client-asserted input-audit context, while a receipt backLink can cryptographically bind the server/proxy-side outcome to the exact attested request.

That gives a clean composition:

user turn context (turnId) → attested request → signed outcome receipt

I will keep this out of SEP-2817’s normative scope, but treat the receipt layout and vectors as strong input for the follow-up server-authoritative execution/decision record work.

@AgentGymLeader

Copy link
Copy Markdown

Thanks, this is useful prior art for the follow-up layer.

The part I would preserve most carefully is the split between the two joins:

  • backLink pins a signed outcome to the exact attested request instance.
  • turnId carries the human-turn correlation that a nonce or digest should not try to replace.

That keeps SEP-2817’s boundary clean: client-asserted intent context remains input-audit metadata, while the server/proxy-side receipt proves what actually happened. I would be cautious about standardizing the full receipt envelope here, but the backLink + turnId composition seems like a good implementation data point for the decision-record follow-up.

@chopmob-cloud

This comment was marked as spam.

@vaaraio

vaaraio commented May 30, 2026

Copy link
Copy Markdown

Two things worth separating in the composition, since they decide whether the follow-up SEP needs one new shape or two.

An allow/deny decision and the execution outcome are the same server-authoritative record, not two stacked layers. A denied call is audit evidence, so the decision belongs as a field on the outcome rather than a slot above it. In the receipt that field is outcomeDerived.status with executed | refused | errored; a refused call still produces a signed record, just with no result commitment. If you want the pre-execution decision captured as well, it's the same envelope written before the side effect and bound to the same attestation. One shape, two timestamps, not two SEPs.

What you bind to matters more than where the decision sits. Binding a decision to SHA-256(JCS({agent_id, action_type, scope, timestamp_ms})) pins it to a description of the action that anyone holding those four fields can recompute. The receipt's backLink pins the digest over the full 2787 wire envelope with the signature included, plus the nonce echo, so it names the exact signed request instance and no party can reassert it. A pre-execution decision record wants that same binding. Otherwise the space between the declared scope and the resolved tool-and-arguments is the admission-to-execution gap the record is meant to close. Bind to the attestation instance, not to a recomputable scope hash.

On the SETTLED/REVERSED tail: settlement and reversal are transaction semantics, and most tools/call invocations don't have them. A read or a shell command either executes or it doesn't. I'd keep the normative outcome enum small (executed, refused, errored) and let domains that actually have a settlement lifecycle model it above the receipt, rather than baking a finance-shaped state machine into the general decision record.

So the composition already on the table is the one I'd keep: turnId for the human-turn join, backLink for the cryptographic request-to-outcome bind, and the allow/deny decision as a field on that signed outcome. v0.45.1 ships the rebind-safe proxy plus the offline vaara receipt verify against the v0 vectors, if checking the shape against something running is useful.

@hangum

hangum commented May 30, 2026

Copy link
Copy Markdown
Author

Thanks both — this is useful follow-up material.

My current read is that SEP-2817 should stay unchanged: turnId provides user-turn correlation for client-asserted input-audit context.

For the follow-up decision/execution-record work, I agree the general MCP shape should stay small and domain-neutral: a server-authoritative record with a request backlink and an outcome status such as executed / refused / errored. Domain-specific settlement states can layer above that where needed.

@XuebinMa

Copy link
Copy Markdown

Agreed on the back-link — that's the right critique, and it's the gap. agent-guard's ExecutionProof signs payload_hash (SHA-256 of the request payload), so today it binds the request content, not a specific signed request instance. That's enough to detect content tampering but it doesn't let a verifier prove "this outcome answers that exact attested call" — which is precisely what your backLink (digest over the full attestation wire bytes incl. signature, plus the nonce echo) provides. Content binding is recomputable; instance binding is not. For the follow-up that's the property worth making normative.

So the composition you've drawn is the one I'd build to as well: turnId (SEP-2817) for the human-turn join, backLink for the cryptographic request→outcome bind, decision-as-a-field on the signed outcome rather than a stacked layer.

One implementer data point on that last part, since agent-guard already separates the two timestamps you mention. Its pre-execution decision is GuardDecision { Allow, Deny, AskUser }, written before the side effect; the post-execution outcome (exit_code, sandbox type) is written after. A Deny produces a signed record with no execution — "refused is audit evidence," same as your outcomeDerived.status: refused. The one state worth a normative slot beyond executed | refused | errored is the human-review/deferral case (this thread's REFER): a decision record exists but the outcome is pending, not yet executed. Whether that's a fourth status or just "decision written, outcome absent" is a real modeling choice for the follow-up.

Keeping the core enum small and domain-neutral (as you and @hangum landed) is right; settlement lifecycles layer above. Happy to align ExecutionProof with an explicit request-attestation back-link when the follow-up shape firms up.

@vaaraio

vaaraio commented May 30, 2026

Copy link
Copy Markdown

@XuebinMa agreed, and REFER is the right thing to pin down before the enum freezes.

I'd keep it off the outcome status. executed | refused | errored answers "what happened when it ran." REFER answers "what was decided," and a deferral is a decision with no outcome yet, not a fourth kind of outcome. Folding it into the outcome enum forces every consumer reading status to handle a value that means "ignore the rest of this record, nothing ran."

The cleaner cut is the two-record shape from earlier in the thread: a decision record written before the side effect, an outcome record written after, both bound to the same attestation by backLink. The decision record carries the decision axis (allow / deny / refer); the outcome record carries the outcome axis (executed / errored, or absent). A denied call collapses to a single record because deny is terminal. A deferral is a signed decision record with decision: refer and no sibling outcome record yet; if the human approves later, the outcome record is written then and back-links to the same attestation. So "decision written, outcome pending" is observable as exactly that, a decision record with no outcome record, without growing the outcome enum or inventing a pending state that later has to be reconciled.

That keeps the normative outcome enum as small and domain-neutral as @hangum landed on, and gives the human-review lifecycle a home on the decision axis rather than the outcome axis. agent-guard's GuardDecision { Allow, Deny, AskUser } is already that axis; AskUser is your refer. The only addition is binding that decision record to the attestation instance the same way the outcome record is, so a verifier can prove the deferral and the eventual execution answer the same attested call.

@chopmob-cloud

This comment was marked as spam.

@vaaraio

vaaraio commented May 31, 2026

Copy link
Copy Markdown

On the follow-up server-authoritative record work, two updates.

Vaara v0.48.0 ships the external time anchor for the audit chain. The chain head gets a trusted timestamp (an RFC 3161 token by default, or an eIDAS qualified timestamp where one is required), so the head's existence is provable against a clock the runtime does not control, even after a signing-key compromise. That is the post-compromise backdating defense a server-authoritative execution record needs, and it verifies offline.

I also drafted the follow-up signed-execution-record SEP this thread keeps pointing at: a decision record before the side effect, an outcome record after, paired by backLink, server-signed (issuerAsserted, a different trust surface from the client-asserted plannerDeclared claims in SEP-2787), JCS-canonical, offline-verifiable. It reuses the ExecutionReceipt fields already shipping in Vaara, so the spec has a reference implementation behind it, not just text.

Draft: https://github.com/vaaraio/vaara/blob/main/docs/sep/sep-server-execution-record.md

I can open it against the SEP process if that's useful.

@chopmob-cloud

This comment was marked as spam.

@hangum

hangum commented May 31, 2026

Copy link
Copy Markdown
Author

Thanks — this looks like useful material for a separate server-authoritative execution-record SEP.

For this PR, I would keep SEP-2817 focused on client-asserted input-audit context only. If you open the signed execution record draft separately, I’d be happy to review it there so this PR can stay scoped.

@vaaraio

vaaraio commented May 31, 2026

Copy link
Copy Markdown

Opened it as #2828, kept to the server-authoritative half so SEP-2817 stays scoped to client-asserted input audit.

It defines a decision record before the side effect (the allow/block/escalate verdict plus the risk basis) and an outcome record after (executed/refused/errored plus a result commitment), paired by backLink to the SEP-2787 attestation instance and signed by the enforcement point. It reuses the SEP-2787 canonicalization and signing stack, so a 2787 verifier needs no new cryptographic code. Reference implementation and an offline standard-library verifier are in the repo.

Thanks for the offer to review there.

@localden localden added SEP draft SEP proposal with a sponsor. labels Jun 8, 2026
@localden localden added proposal SEP proposal without a sponsor. and removed draft SEP proposal with a sponsor. labels Jun 8, 2026
@JM-Lab

JM-Lab commented Jun 25, 2026

Copy link
Copy Markdown

Adoption evidence from a shipping host (Spring AI Playground, Apache-2.0)

Disclosure: this comes from a maintainer of the project below, not a neutral observer. Offering it as a cross-implementation data point, since the thread is collecting them.

The project already propagates identity and correlation context through tool-call execution for audit: session and conversation identifiers, a per-turn message id, trace/span correlation, and optional user identity context, carried in MDC and observability spans.

The context already exists and is operationally useful. What is missing today is a standard protocol location to carry it across the host/server boundary: right now it lives only in the host's own logs and spans and never reaches an external MCP server, because there is no standard _meta slot for it.

On the field mapping, the overlap is strongest at correlation. turnId provides the missing correlation layer between conversation or session scope and individual MCP requests: it lines up with the conversation plus per-turn message id the project already tracks, and a standard slot would let that correlation reach the server side rather than stopping at the host's logs. model is already available. invocationReason and userIntent would be new fields to populate, but the standardized slot is the valuable part.

One thing that matches your framing directly: this is kept strictly as audit context, fully separate from the policy decision. Authorization and the human-in-the-loop approval gate run on a separate path and never read this context, so it is not visible to policy evaluation. "Not authorization evidence" is the right constraint, and it reflects how a shipping host already treats this context.

Happy to share the exact field set and how it threads through tool execution if a concrete reference shape would help.

Links:

@hangum

hangum commented Jul 8, 2026

Copy link
Copy Markdown
Author

Thanks @JM-Lab — this is a very helpful shipping-host data point.

The Spring AI Playground mapping matches the intended boundary of SEP-2817 well: the context already exists operationally as audit/correlation data, but it does not currently have a standard protocol location across the MCP host/server boundary.

I also appreciate the confirmation that this context is kept separate from authorization and approval policy. That is exactly the constraint this SEP is trying to preserve: aiInvocation explains the client/model-side input context, but it is not authorization evidence.

Given this additional adoption evidence, I would appreciate maintainer guidance on whether SEP-2817 can move toward sponsorship/review, or whether any specific adjustment is needed before that step.

@elang2

elang2 commented Aug 23, 2026

Copy link
Copy Markdown

Intermediary data point. Disclosure: I maintain mcp-audit-gateway, a transparent proxy between client and server that writes each tools/call as a signed, hash-chained audit record.

The gateway now extracts turnId, invocationReason, and model from io.modelcontextprotocol/aiInvocation in request _meta, binds them into the audit record, and echoes turnId in response _meta per the server MAY clause. Those fields land with explicit client-asserter attribution in the record's parties array, distinct from fields the gateway directly witnesses (toolName, timestamp, durationMs, success, and 6 others). Policy-denied calls still carry the client-asserted context in their signed record, so "denied" is audit evidence alongside "executed."

"parties": [
  { "party": "gateway", "role": "witness", "scope": ["id","timestamp","method","toolName","namespace","upstream","principal","durationMs","success","errorCode","previousHash"] },
  { "party": "client", "role": "asserter", "scope": ["aiInvocation"] }
]

One observation from implementing this: intermediaries that rewrite request bodies (policy proxies, body-logging gateways) could strip or mutate the aiInvocation key. A one-line normative note saying intermediaries SHOULD forward the key unmodified would prevent the context from silently dying at the first proxy hop.

Implementation (extraction, echo, party attribution)

@hangum

hangum commented Aug 24, 2026

Copy link
Copy Markdown
Author

Thanks @elang2 — this is a very useful intermediary/proxy data point.

The party: client / role: asserter versus party: gateway / role: witness split is exactly the boundary SEP-2817 is trying to preserve: aiInvocation is useful audit and correlation context, but it remains client-asserted and separate from what the intermediary or server directly observes.

I also agree with the intermediary concern. Since proxies and gateways may rewrite request bodies, it is worth making the forwarding expectation explicit so this context does not disappear at the first hop.

I can add a short normative note along these lines:

Intermediaries that forward MCP requests SHOULD preserve _meta["io.modelcontextprotocol/aiInvocation"] unmodified unless they have a specific policy reason to remove it.

That keeps the field useful for audit pipelines while still allowing intermediaries to enforce local policy when needed.

@XuebinMa

Copy link
Copy Markdown

@elang2 @hangum — the parties split is the same distinction we shipped last week in a different shape, so here's a second implementation data point plus one caution from having got it wrong first.

agent-guard is a standalone Rust execution-control runtime, not an MCP intermediary, so this is about record shape rather than SEP conformance. It has two paths: one where the runtime holds the executor start to finish, and one where it hands the action back to the host, which runs it outside the boundary and reports an exit code afterwards. Until last week both emitted the same audit record type. They now don't:

ExecutionFinished(ExecutionEvent),
/// Outcome claimed by a host after execution left the Guard's boundary.
/// Unlike `ExecutionFinished`, this is transcribed rather than witnessed.
ExecutionReported(ExecutionEvent),

Yours carries the distinction in a field (role: witness vs role: asserter, with an explicit scope); ours carries it in the type. The field version is more expressive — you can attribute per-field inside a single record, which we can't. The type version is harder to lose. That difference is the caution.

We effectively had the field version first: the handoff record already carried sandbox_type: "host-handoff", with a doc comment saying it existed precisely so consumers could tell the two apart. It was correct at write time and it still failed, because the consumer collapsed it. Our verifier reduced the record to a counter — AuditRecord::ExecutionFinished(_) => report.executions_finished += 1, discarding the body — so a host-asserted exit_code: 0 and a runtime-witnessed execution landed in the same total, inside the tool whose entire job is turning the log into evidence. The distinction was representable, attributed, documented, and gone by the time anyone read a summary.

@safal207 named the invariant that falls out of this, over in crewAIInc/crewAI#5888: a reduction used for an authority or outcome claim must preserve every evidence distinction that can change the strength of that claim. Two records can share a type and an exit code and still be non-interchangeable witnesses; if a reducer merges provenance classes, the resulting claim has to be bounded by the weaker class rather than silently inheriting the stronger one's meaning.

So the question I'd put to the gateway, since the write side is clearly right: does anything downstream of the signed record reduce across the parties boundary? Dashboards, alert rules, a "calls succeeded" count, an export that flattens the record. That's where ours broke, and it broke silently — no test failed, because nothing was wrong with the record.

We pinned it with a regression that asserts the negative rather than the positive: no host-supplied result can ever produce a witnessed-execution record, verified by reading the audit stream back rather than by inspecting the emitter. Asserting that the new record gets emitted proves emission; it doesn't prevent reintroduction.

Worth flagging too that #5888 has been formalizing this into invariants with an executable conformance suite — witness provenance, deterministic projection over the witness set, and the reduction rule above. Different ecosystem, same boundary, and further along on the theory than anything I have.

@elang2

elang2 commented Aug 24, 2026

Copy link
Copy Markdown

@XuebinMa good question, and the honest answer is that the write side is correct but the read side hadn't been tested against this failure mode until now.

Today mcp-audit verify checks chain integrity (hash continuity, signature validity, monotonic timestamps) but treats every record as a flat unit. It does not report provenance class separately. mcp-audit tail renders all records the same way regardless of party attribution. There was no reduction layer producing outcome counts.

So nothing downstream was reducing across the boundary, but only because nothing downstream was reducing at all. The bug you describe would appear the moment someone writes a consumer that counts outcomes.

I just added the regression tests you described. Two assertions that verify the negative by reading the record back:

  1. No client-asserted field can appear in the gateway's witness scope (and vice versa)
  2. The scope arrays have zero overlap, so any reducer that merges across the boundary is detectable

Commit: elang2/mcp-audit-gateway@a87b09b

The invariant from crewAI #5888 ("a reduction used for an authority claim must preserve every evidence distinction that can change the strength of that claim") is exactly what the parties array makes machine-checkable. The tests now enforce that the distinction survives at the record level. A consumer that collapses them would need to explicitly discard the scope metadata to do so.

@hangum the intermediary forwarding note looks right. Thanks for picking that up.

@safal207

Copy link
Copy Markdown

Сюэбин Ма оставил комментарий (modelcontextprotocol/modelcontextprotocol#2817)
@elang2 @hangum — разделение parties — это то же самое различие, которое мы внедрили на прошлой неделе, но в другой форме, поэтому вот второй пример реализации, а также одно предостережение, основанное на том, что мы ошиблись в первый раз.

agent-guard — это автономная среда выполнения Rust для управления выполнением, а не посредник MCP, поэтому речь идёт о форме записи, а не о соответствии стандарту SEP. У неё два пути: один, где среда выполнения контролирует выполнение от начала до конца, и другой, где она передаёт действие обратно хосту, который выполняет его за пределами заданных границ и сообщает код завершения. До прошлой недели оба варианта генерировали один и тот же тип записи аудита. Теперь это не так:

ExecutionFinished ( ExecutionEvent ) ,
/// Outcome claimed by a host after execution left the Guard's boundary.
/// Unlike ExecutionFinished , this is transcribed rather than witnessed .
ExecutionReported ( ExecutionEvent ) ,
В вашем случае различие заключается в поле ( role: witness против role: asserter , с явно заданной scope ); в нашем — в типе. Версия с полем более выразительна — вы можете присваивать атрибуты каждому полю внутри одной записи, чего мы не можем. Версию с типом сложнее потерять. Именно это различие и является предостережением.

По сути, у нас сначала была версия поля: запись о передаче уже содержала sandbox_type: "host-handoff" , с комментарием в документации, указывающим на то, что она существовала именно для того, чтобы потребители могли различать эти два события. Она была корректна на момент записи, и всё равно произошла ошибка, потому что потребитель её свернул. Наш верификатор сократил запись до счётчика — AuditRecord::ExecutionFinished(_) => report.executions_finished += 1 , отбросив тело записи — таким образом, подтвержденный хостом exit_code: 0 и подтвержденное во время выполнения выполнение попадали в один общий итог внутри инструмента, вся задача которого состоит в превращении журнала в доказательство. Различие было представимо, атрибутировано, задокументировано и исчезало к тому моменту, когда кто-либо читал сводку.

@safal207 назвал инвариант, вытекающий из этого, в crewAIInc/crewAI#5888 : редукция, используемая для утверждения авторитета или результата, должна сохранять все различия в доказательствах, которые могут изменить силу этого утверждения. Две записи могут иметь общий тип и код завершения и при этом оставаться невзаимозаменяемыми свидетелями; если редуктор объединяет классы происхождения, результирующее утверждение должно быть ограничено более слабым классом, а не молчаливо наследовать значение более сильного класса.

Итак, вопрос, который я бы задал шлюзу, поскольку сторона записи явно права: происходит ли какое-либо уменьшение количества данных после подписанной записи за пределами границ parties ? Панели мониторинга, правила оповещения, счетчик «успешных вызовов», экспорт, который сглаживает запись. Именно здесь у нас произошел сбой, и он произошел незаметно — ни один тест не провалился, потому что с записью все было в порядке.

Мы зафиксировали это с помощью регрессионного анализа, который подтверждает скорее отрицательную, чем положительную сторону вопроса: ни один результат, предоставленный хостом, никогда не сможет создать запись о подтвержденном выполнении, которая проверяется путем считывания потока аудита, а не путем проверки отправителя. Утверждение о том, что новая запись отправлена, доказывает отправку; это не предотвращает повторное введение.

Стоит также отметить, что в задаче #5888 это формализуется в виде инвариантов с помощью исполняемого набора проверок соответствия — происхождение свидетелей, детерминированная проекция на множество свидетелей и правило редукции, описанное выше. Другая экосистема, те же границы, и теория продвинулась дальше, чем в любом из моих проектов.


Ответьте на это письмо напрямую, просмотрите его на GitHub или отпишитесь от рассылки .
Вы получили это сообщение, потому что вас упомянули.

elang2 оставил комментарий (modelcontextprotocol/modelcontextprotocol#2817)
@XuebinMa хороший вопрос, и честный ответ таков: операция записи выполняется корректно, но операция чтения до сих пор не проверялась на наличие этого сбоя.

Сегодня mcp-audit verify проверяет целостность цепочки (непрерывность хеша, действительность подписи, монотонные временные метки), но рассматривает каждую запись как единую единицу. Он не сообщает о классе происхождения отдельно. mcp-audit tail отображает все записи одинаково независимо от принадлежности к той или иной стороне. Отсутствовал слой редукции, который бы подсчитывал количество результатов.

Таким образом, за границей ничего не уменьшалось, но только потому, что ничего не уменьшалось вообще. Описанная вами ошибка появляется в тот момент, когда кто-то пишет потребитель, который подсчитывает результаты.

Я только что добавил описанные вами регрессионные тесты. Два утверждения, которые подтверждают отрицательное значение путем обратного считывания записи:

Ни одно поле, подтвержденное клиентом, не может отображаться в области видимости шлюза (и наоборот).
Массивы областей видимости не перекрываются, поэтому любой редуктор, пересекающий границу, может быть обнаружен.
Коммит: elang2/mcp-audit-gateway@ a87b09b

Инвариант из запроса crewAI #5888 («сокращение, используемое для утверждения об авторитетности, должно сохранять все различия в доказательствах, которые могут изменить силу этого утверждения») — это именно то, что делает машинно проверяемым массив parties . Теперь тесты гарантируют, что различие сохраняется на уровне записи. Потребителю, который их объединяет, потребуется явно удалить метаданные области видимости, чтобы это сделать.

@hangum похоже, что уведомление о пересылке от посредника корректно. Спасибо, что обратили на это внимание.


Ответьте на это письмо напрямую, просмотрите его на GitHub или отпишитесь от рассылки .
Вы получили это сообщение, потому что вас упомянули.

elang2 added a commit to elang2/mcp-audit-gateway that referenced this pull request Aug 26, 2026
…primitive

Introduces `projectByRole(record, role, party?)` and companion helpers as
the read-side primitive consumers call to preserve scope boundaries under
aggregation. The `parties[]` attribution shipped in v0.2.0 encodes
multi-party role/scope on write; without a scope-projecting read
primitive, consumers can silently collapse the witness/asserter
distinction when reducing across records.

Design decisions locked before implementation:

- Distinct WitnessProjection type, not a nulled AuditRecord. A nulled
  record is type-indistinguishable from a legitimate partial record and
  invites accidental rehash-as-record.
- Domain-tagged canonical digest. `projectionDigest(p)` reuses shipped
  `canonicalizeValue` for the value canonicalization and wraps the
  result as `JSON.stringify([PROJECTION_DOMAIN_TAG, canonical])`. Two
  steps, both replicable by cross-language re-implementers: (a) the
  shared canonical discipline, (b) the outer array wrap for domain
  separation. A projection digest cannot collide with a record digest
  by construction (records serialize as `{...}`; projections as `[...]`).
- Non-integer numbers, unsafe integers, and lone surrogates in projected
  fields all throw via canonicalizeValue. This is the same producer
  requirement Vector 2 of the C-REC harness enforces on records.
  `durationMs` is currently assigned via `Date.now() - startTime` in
  every production path (integer milliseconds by convention, not type);
  a future change to a float source would surface here as an
  `unsafe number` throw.
- Role-primary API with optional party refinement.
- Lossy for out-of-scope fields; in-scope fields preserved verbatim.
  `projectionOf` carries the SHA-256 of the source record so a consumer
  can locate the record when needed. `projectionOf` uses `hashRecord`
  (raw `JSON.stringify` + sha256, matching how the chain hashes records
  since v0.7.0's octets-first change); `projectionDigest` uses
  `canonicalizeValue` on the projection body for cross-implementation
  stability. The two hash outputs use different canonicalization
  disciplines intentionally.

Runtime robustness:

- Imports hashRecord from audit-log directly (no duplication drift).
- Filters unknown role values in enumerators (defensive against
  untrusted JSON input where the TypeScript type erases at runtime).

Referenced discussions:

  - modelcontextprotocol/modelcontextprotocol#2817 (SEP-2817): parties[]
    attribution integrated into the SEP text by hangum; XuebinMa's Aug-24
    comment (issuecomment-5390870362) describes agent-guard's
    ExecutionFinished vs ExecutionReported split after their consumer
    collapsed the record-level distinction under a counter.
  - Write-side regression tests at commit a87b09b lock scope-overlap and
    cross-scope-leakage invariants at ingest time.
  - CycloneDX/specification#1016 (Silentpartnercoding): a
    field-mapping-verifier pattern that can call projectionDigest to
    compute a scope-bounded digest independently.

Tests (36 total) cover: cross-scope leakage prevention, determinism,
digest-inequality domain separation (projection digest never equals
record digest), structural domain separation (projection canonical
bytes start with `[`, records with `{`), role vs party axis,
reduction-preserves-scope pattern, and edge cases (float durationMs
throws consistently with Vector 2, unknown role values filtered,
dotted-path scope entries return undefined, absent parties[],
deduplication, overlapping scopes).

No behavior changes to existing exports.
elang2 added a commit to elang2/mcp-audit-gateway that referenced this pull request Aug 26, 2026
…primitive

Introduces `projectByRole(record, role, party?)` and companion helpers as
the read-side primitive consumers call to preserve scope boundaries under
aggregation. The `parties[]` attribution shipped in v0.2.0 encodes
multi-party role/scope on write; without a scope-projecting read
primitive, consumers can silently collapse the witness/asserter
distinction when reducing across records.

Design decisions locked before implementation:

- Distinct WitnessProjection type, not a nulled AuditRecord. A nulled
  record is type-indistinguishable from a legitimate partial record and
  invites accidental rehash-as-record.
- Domain-tagged canonical digest. `projectionDigest(p)` reuses shipped
  `canonicalizeValue` for the value canonicalization and wraps the
  result as `JSON.stringify([PROJECTION_DOMAIN_TAG, canonical])`. Two
  steps, both replicable by cross-language re-implementers: (a) the
  shared canonical discipline, (b) the outer array wrap for domain
  separation. A projection digest cannot collide with a record digest
  by construction (records serialize as `{...}`; projections as `[...]`).
- Non-integer numbers, unsafe integers, and lone surrogates in projected
  fields all throw via canonicalizeValue. This is the same producer
  requirement Vector 2 of the C-REC harness enforces on records.
  `durationMs` is currently assigned via `Date.now() - startTime` in
  every production path (integer milliseconds by convention, not type);
  a future change to a float source would surface here as an
  `unsafe number` throw.
- Role-primary API with optional party refinement.
- Lossy for out-of-scope fields; in-scope fields preserved verbatim.
  `projectionOf` carries the SHA-256 of the source record so a consumer
  can locate the record when needed. `projectionOf` uses `hashRecord`
  (raw `JSON.stringify` + sha256, matching how the chain hashes records
  since v0.7.0's octets-first change); `projectionDigest` uses
  `canonicalizeValue` on the projection body for cross-implementation
  stability. The two hash outputs use different canonicalization
  disciplines intentionally.

Runtime robustness:

- Imports hashRecord from audit-log directly (no duplication drift).
- Filters unknown role values in enumerators (defensive against
  untrusted JSON input where the TypeScript type erases at runtime).

Referenced discussions:

  - modelcontextprotocol/modelcontextprotocol#2817 (SEP-2817): parties[]
    attribution integrated into the SEP text by hangum; XuebinMa's Aug-24
    comment (issuecomment-5390870362) describes agent-guard's
    ExecutionFinished vs ExecutionReported split after their consumer
    collapsed the record-level distinction under a counter.
  - Write-side regression tests at commit a87b09b lock scope-overlap and
    cross-scope-leakage invariants at ingest time.
  - CycloneDX/specification#1016 (Silentpartnercoding): a
    field-mapping-verifier pattern that can call projectionDigest to
    compute a scope-bounded digest independently.

Tests (36 total) cover: cross-scope leakage prevention, determinism,
digest-inequality domain separation (projection digest never equals
record digest), structural domain separation (projection canonical
bytes start with `[`, records with `{`), role vs party axis,
reduction-preserves-scope pattern, and edge cases (float durationMs
throws consistently with Vector 2, unknown role values filtered,
dotted-path scope entries return undefined, absent parties[],
deduplication, overlapping scopes).

No behavior changes to existing exports.

@ozereray ozereray left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One security boundary I’d keep explicit in the split between invocation audit context and follow-up decision records: the audit envelope explains why the client says it invoked a tool, but it must remain cryptographically / structurally distinct from the server-authoritative execution decision.

For consequential tools, a useful follow-up invariant is: an execution receipt should bind the concrete tool name + canonical argument digest + caller/agent identity + policy/version + decision outcome to the resulting execution. That prevents a valid audit context from being mistaken for authorization, and makes replay or argument mutation detectable after approval.

That separation also maps cleanly to runtime enforcement systems: intent metadata is contextual evidence; the allow/deny/require-approval decision is the control point; the execution receipt is the proof of what actually happened.

Curious whether the follow-up SEP is expected to standardize that binding, or intentionally leave it to implementations.

@XuebinMa

Copy link
Copy Markdown

@ozereray — I can't speak for where this SEP goes; that's @hangum's and the maintainers' call. But one fact bears on your question, and it isn't obvious from this thread.

The follow-up that would have carried exactly that binding — #2828, "Server-Side Signed Execution Record for MCP Tool Calls" — was closed by its own author on 2026-07-18 and re-homed to an IETF Internet-Draft. So as of today nothing inside MCP is on track to standardize the execution-side binding, and #2817 is deliberately only the audit-context half. Your instinct to keep the two cryptographically distinct is, right now, the whole of the split that exists here.

On the invariant itself I'd add one caveat from having shipped it and then broken it. Binding "tool name + canonical argument digest + caller identity + policy version + decision outcome" is necessary but not sufficient: each bound field has to be the value the executor actually consumes. In our own broker the approval bound a target URL resolved one way, while execution resolved the same target a second way and reached a different endpoint. Recompute-and-compare passed on every run, consistently, and the signed receipt described a destination the side effect never touched. The binding was self-consistent and wrong.

So if a follow-up does standardize this, the part worth making normative is probably not the field list — that's the easy half, and everyone converges on it — but the recompute-immediately-before-execution step and, specifically, what a verifier is required to reject. A field list without that is checkable and still permits the failure above.

(Scope on my side: agent-guard verifies its own Ed25519 receipts, not SEP-2817 records. I'm answering from implementation experience, not claiming coverage of this proposal.)

@ozereray

ozereray commented Sep 10, 2026 via email

Copy link
Copy Markdown

@vaaraio

vaaraio commented Sep 11, 2026

Copy link
Copy Markdown

@XuebinMa your read of the pushurl case matches mine: the recompute step is the property that matters, and a field list does not reach it. One data point from having built the execution-side gate, since you mentioned where #2828 went. That work continues as draft-sirkkavaara-vaara-receipt, now at -10.

The proxy runs the recompute as a separate gate after the policy decision, and it can refuse a call the policy allowed. The order is: operator filter, policy decision, attestation and a grant minted over the argument digest, then the gateway re-verifies the runtime arguments against that digest, then forward upstream. The re-verification is the last thing before the forward. A constrained tool whose arguments changed after the grant was minted comes back refused, with the policy decision still recorded as allow, so the trail carries both. The gate sequence is recorded in order for the same reason you are arguing: a reader can tell a gate that ran and allowed from one that never ran, and a field list does not give you that.

Your divergence still applies to this shape, in a relocated form. A proxy can bind the arguments it forwards. It cannot see a second resolution inside the upstream server. If the upstream re-resolves a target after receiving the call, the record describes what was sent and not what the server then did with it. For that case the executor has to attest, and the gate cannot reach it.

The code is AGPL if anyone prefers checking the ordering to taking my word for it: _handle_tools_call in src/vaara/integrations/mcp_proxy.py, and verify_grant reached from src/vaara/credential/gateway.py.

Copy link
Copy Markdown

Useful implementation data point. I think this adds one narrow conformance consequence to the #5888 framing: policy=ALLOW and execution=REFUSED can both be true, so a reducer must preserve gate identity and ordering rather than collapse the trail into one verdict.

A compact negative vector would be:

ALLOW(A) -> grant(A) -> runtime args mutate to B -> recompute mismatch -> REFUSED / no forward

while the original policy ALLOW remains independently observable.

The upstream re-resolution caveat also keeps the boundary in the right place: the last component that resolves or consumes the side effect must bind or attest the values it actually used.

@ozereray

ozereray commented Sep 11, 2026 via email

Copy link
Copy Markdown

@XuebinMa

Copy link
Copy Markdown

@safal207 I ran your negative vector. Built it as a bundle_vectors_v1 case, rebuilt the chain with the corpus's own secret, scored it: 3 of 3 accept — your vector plus two controls.

The short version: this verifier does not collapse the trail, which is what your rule asks for. But all three reports come back identical, including a control where the refusal record carries no call_id at all. So the refusal record is inert — the schema can express "refused at the enforcement point" and nothing consumes it, which leaves unaccounted meaning both "we established nothing ran" and "the evidence stops here".

Full numbers and the two controls are on a2aproject/A2A#1575, where the corpus lives — parking them there rather than growing this thread.

@vaaraio your ordering point lands on the same spot from the proxy side, and your upstream-re-resolution caveat applies to us too: our pushurl failure was that divergence one layer down from yours. Neither of us can close it from the gate.

@damoclais

Copy link
Copy Markdown

The recompute step is the part worth writing into the spec. XuebinMa's pushurl case shows why a field list cannot cover it: every field was bound faithfully, the check passed every time, and the record still described an action that never happened.

I build agent security tooling, so this comes from the build side.

One addition on what a checker must reject. A check only helps someone who was not there if the record says which version of the rules it was made under. Ours carries a version label, and the checker takes the rule version from the record rather than assuming it. We had two versions of one signing rule live at once, the older one weaker; a checker assuming the rules it knew would have accepted the weaker one silently. The reverse holds too, where a checker on newer rules calls a sound record tampered because the format moved on.

Which gives the sentence the spec needs. When a checker meets a rule version it does not recognise - refuse or may it use the one it knows? Refusing is safer and it has to be written down because assuming is easier to code.

I would also keep the ordering point from safal207 and ozereray: a reader should be able to tell a gate that ran and allowed from one that never ran.

@hangum

hangum commented Sep 15, 2026

Copy link
Copy Markdown
Author

Updated the branch against current main and resolved the SEP index conflict. The PR is mergeable again and all current checks are passing.

Thanks @ozereray and @damoclais. I agree with the separation.

For SEP-2817 I would keep aiInvocation limited to client-asserted input-audit context: why the model/client invoked the tool, which model produced the invocation, user intent where appropriate, and a turn/request correlation id. It should remain distinct from authorization, enforcement, and execution evidence.

The recompute step, rule/policy version binding, unknown-version rejection, gate ordering, stable call correlation, approval lifecycle, and execution receipts all seem like the right scope for follow-up decision/receipt SEP work.

@ozereray

ozereray commented Sep 15, 2026 via email

Copy link
Copy Markdown

@hangum

hangum commented Sep 15, 2026

Copy link
Copy Markdown
Author

Thanks @ozereray — agreed.

That layering matches the intended boundary for SEP-2817. I’ll keep this PR limited to client-asserted audit context and correlation, and leave authorization, enforcement, execution evidence, and receipt binding to follow-up decision/receipt work.

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

Labels

proposal SEP proposal without a sponsor. SEP

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.