Skip to content

18 — Spec Corrections RFC (P0)

IMPORTANT

Status: Accepted (2026-07-29). This RFC resolves the P0 specification bugs identified by the maintainer directive §3. Every correction here is normative; the affected documents (02, 03, 05, 06, 07, 08, 09, plus 01/04 for navigation) are patched in the same change, and the decision log (13) records each as D21–D26. Requirement IDs (AS-…) resolve in spec/conformance.json at the repository root.

Precedence reminder (directive §4.3): accepted decision records — including this RFC — outrank all other documents. A document contradicting this RFC is a spec bug.


Correction 1 — Two-stage policy pipeline: validated effective input precedes input-aware policy and confirmation (D21)

Problem. The previous pipeline ran the whole policy chain — including the confirmation decision — at phase 4, before the input phase computed and validated the effective input (locked-field enforcement + agent parse + bind() + merge + full-schema validation). Confirmation evidence claims to bind to the exact operation and input the user approved; it cannot truthfully bind to an input that does not exist yet. Policies also received the raw, unvalidated agent input (input?: JsonValue), inviting input-aware decisions over data that had passed no schema.

Decision. Policy concerns split into pre-input authorization and post-input invocation policy, with the type system making it impossible for an input-aware policy to see raw input:

ts
export interface AgentPolicy {
  name: string;
  onDiscovery?(ctx: AgentPolicyContext): DiscoveryDecision;

  /** Phase 4. No agent input is available here — by construction. */
  onAuthorize?(
    ctx: AgentAuthorizationContext,
    next: () => Promise<AgentInvocationResult>,
  ): Promise<AgentInvocationResult>;

  /** Phase 6. Receives ONLY the validated effective input. */
  onInvoke?(
    ctx: AgentInvocationPolicyContext,
    next: () => Promise<AgentInvocationResult>,
  ): Promise<AgentInvocationResult>;
}

export type AgentAuthorizationContext = AgentPolicyContext;

export interface AgentInvocationPolicyContext extends AgentAuthorizationContext {
  invocationId: string;
  effectiveInput: JsonValue;
}

The normative pipeline becomes ten phases (full text in 02 §invocation-pipeline):

text
 1 dedupe + request-conflict check          6 post-input invocation policies
 2 resolve + staleness tokens                 (input-aware rate, confirmation
 3 availability re-check                       decision + evidence validation,
 4 pre-input authority policies                audit enrichment)
   (authn, authz, tenant, environment,      7 capability precondition
    discovery re-check)                     8 concurrency slot
 5 validated effective input                9 execute (timeout + AbortSignal)
   (reject locked fields → parse agent     10 settle (output, classify, dedupe
    input → bind() → merge → full-             record, audit, ordered events)
    original-schema validation)
  • CONFIRMATION_REQUIRED is produced at phase 6 and its record stores the effective input — a binding value that changed between discovery and invocation is therefore visible in the confirmation summary, and a malformed binding or supplied locked field fails at phase 5, before any confirmation record exists (AS-INVOKE-003, AS-INVOKE-004).
  • Confirmation evidence binds to a canonical request digest over { surfaceId, registrationId, capabilityId, consumerKey, effectiveInput, effect }, computed with the canonical JSON encoding below. Evidence remains single-use and TTL-bound; a digest mismatch is CONFIRMATION_INVALID { reason: "mismatch" } (AS-CONFIRM-001…004).
  • Built-in policy placement: authenticated, hasPermission, tenantBoundary, environment, rateLimit run in onAuthorize (none of them may depend on input); requireConfirmation contributes the phase-6 confirmation decision; audit runs in onInvoke so its enrichment sees the effective input. An input-aware rate policy is authored as onInvoke.
  • Policy contexts additionally expose now(): number (the registry's injectable clock). Built-ins MUST use it — free-running Date.now() in a policy is a determinism defect (08 §determinism).

Canonical JSON encoding (normative). Used for the confirmation digest and the request fingerprint: object keys sorted by UTF-16 code unit; array order significant; undefined properties omitted; numbers MUST be finite (a non-finite number is a serialization defect per D18) and -0 encodes as 0; strings/booleans/null per JSON. The digest is the FNV-1a 64-bit hash of the canonical string for storage/comparison keys, with the confirmation store additionally retaining the exact effective input so evidence matching is exact-value, never hash-only (hash collisions must not validate evidence; AS-CONFIRM-002).

Migration (breaking, Draft API). AgentPolicyInvokeContext (with raw input?) is removed. Policies that read ctx.input move to onInvoke(ctx) and read ctx.effectiveInput; policies that only gated on host/consumer state move to onAuthorize. requireConfirmation({ if, summary }) predicates now always receive the effective input.

Correction 2 — Consumer-scoped, conflict-safe invocation identity (D22, amends D14)

Problem. At-most-once execution was promised per invocationId, but adapters derive that value from provider tool-call ids, which are not unique across consumers, adapters, conversations, providers, page lifetimes, or reconnects. A cache keyed by bare invocationId can join or replay a different request; reusing an id with different input silently succeeded.

Decision.

ts
// Dedupe key (per registry; surfaceId scopes page lifetimes structurally):
key         = consumerKey + "�" + invocationId
consumerKey = consumer.kind + ":" + consumer.id      // runtime-normalized

// Request fingerprint — the request AS ISSUED:
fingerprint = fnv1a64(canonicalJson({
  capabilityId, registrationId: registrationId ?? null,
  instanceId: instanceId ?? null, surfaceVersion: surfaceVersion ?? null,
  input: input ?? null, confirmationId: confirmationId ?? null,
}))

Store behavior (normative, AS-IDENT-001…005):

  1. unknown key → create in-flight record with fingerprint;
  2. same key + same fingerprint → join in-flight, or return cached terminal result;
  3. same key + different fingerprint → fail closed with INVOCATION_CONFLICT (the existing record is untouched; the conflict result itself is never cached);
  4. expired key → new attempt. The dedupe window is bounded (dedupeCacheSize/dedupeCacheTtlMs) — an idempotency window, not a forever guarantee.

New closed error code (07):

ts
{
  code: "INVOCATION_CONFLICT",
  message: "This invocation id was already used for a different request.",
  retry: "with-changes",
  details: { reason: "id-reused-with-different-request" }
}

Agent-visible details MUST NOT expose the prior request's capability, input, or fingerprint material.

Recorded deviation from the directive's recommended shape. The directive sketches effectiveInputDigest inside the fingerprint. The conflict check runs at phase 1, before the effective input exists (Correction 1 places its construction at phase 5); and two identical retried requests may legitimately produce different effective inputs when live bindings moved. The fingerprint therefore binds the request as issued — which is exactly what transport-retry dedupe must join on — while the effective input is bound by the confirmation digest (Correction 1). Confirmation retry keeps the D14 rule: same invocationId + confirmationId; it works because CONFIRMATION_REQUIRED and RATE_LIMITED are never recorded as terminal (AS-IDENT-006).

Anonymous consumers. The default consumer normalizes to embedded:anonymous within one registry; page loads cannot collide because the dedupe store is per-registry (per surfaceId). Adapters MUST supply a stable, per-adapter-instance consumer id whenever more than one consumer can address a registry (09 §adapter contract, duty 1a).

Correction 3 — Navigation completion is independent of owner unmount timing (D23, revisits D16)

Problem. A view: navigation action routinely unmounts its own registering component. Under "first settle wins", a successful route transition could classify as COMPONENT_UNMOUNTED depending on whether the unmount effect ran before the handler's promise resolved — a semantically unstable result that invites pointless retries.

Decision — handler-settlement authority. For actions with effect: "navigation" only:

  • Unregistration MUST NOT settle an in-flight navigation invocation. It still aborts ctx.signal (cooperative, unchanged) and the registration still tombstones normally.
  • The invocation settles exclusively on: handler resolution → ok (with surfaceChanged cueing re-discovery); handler rejection → CANCELLED when ctx.signal is aborted (the transition was abandoned), else EXECUTION_FAILED; timeout → TIMEOUT; external InvokeOptions.signal abort → CANCELLED.
  • Unmount before phase 9 dispatch is unchanged: COMPONENT_UNMOUNTED (the handler never ran).
  • The authoring contract: a navigation handler resolves when the host router accepts or commits the transition, and rejects when the router refuses it. Synchronous-void routers (router.push(); return;) satisfy this trivially — the handler settles in the same task, before any unmount.

A timeout cannot fire after acceptance (resolution settles first), so "timeout after the router already accepted" cannot report failure (AS-NAV-001…004).

Recorded deviation. The directive offers an injected AgentNavigationHost seam as one option. Rejected for v0.1: the handler already is the router adapter, and a second seam would create two places to express one contract. The chosen contract is deterministic because the outcome depends only on handler settlement, never on unmount scheduling. If real routers prove unable to report acceptance from inside a handler, AgentNavigationHost is the designated fallback (tracked in D23).

Correction 4 — Bounded read concurrency; no unbounded runtime collection (D24, amends D13)

Problem. Observations could run with unlimited concurrency; pending confirmations had no size bound. Both violate "every runtime collection is bounded".

Decision. New limits (measured defaults; conservative until the example app produces data):

ts
limits: {
  maxConcurrentObservationsPerConsumer: 8,
  maxConcurrentObservationsTotal: 32,
  maxQueuedObservationsPerConsumer: 8,
  maxPendingConfirmations: 32,
}
  • Observation admission: per-consumer and global concurrency gates are independent; a saturated consumer queues FIFO (up to maxQueuedObservationsPerConsumer), never consuming the action queue; queue overflow and global saturation with a full queue fail RATE_LIMITED { reason: "queue-full", retryAfterMs } (AS-OBS-001…003). One consumer's saturation MUST NOT starve another below its per-consumer allowance beyond the shared global cap.
  • Cancellation, timeout, and settlement release slots; registry disposal drains queues as CANCELLED; queued entries for a disposed consumer are removed leak-free.
  • A confirmation request that would exceed maxPendingConfirmations fails the invocation RATE_LIMITED { reason: "queue-full" } without creating a record — fail closed beats silently expiring an approval the user may be reading.
  • Rate-limit details carry only reason and retryAfterMs — no queue internals.

Correction 5 — Action concurrency contract (D25, defers implementation)

{ mode: "instance" } (per-instance FIFO serialization, queue depth actionQueueDepth) remains the only implemented and the default behavior. The contract below is specified now so adapters and apps do not invent shapes, and deliberately not implemented until the base queue's race tests are green in CI (directive §3.5: hardening, not a slice prerequisite):

ts
export type AgentConcurrency =            // [Experimental — Phase C]
  | { mode: "instance" }                  // default
  | { mode: "capability" }
  | { mode: "key"; key: string }
  | { mode: "parallel"; max: number };    // bounded max REQUIRED

Queue depth is configurable per concurrency group; procedure references default to a conservative group derived from procedure identity + reference registration; concurrency metadata stays internal (not model-visible) until there is evidence of model value. Conformance status: specified.

Correction 6 — Confirmation mode is declared by topology, never defaulted globally (D26)

Problem. createAgentToolset silently defaulted to confirmations: "wait". Wait-mode holds the run open across a human approval — correct for an embedded loop, hostile to remote/streaming loops (transport timeouts, broken reconnects).

Decision.

  • AgentToolsetOptions gains topology: "embedded" | "remote". Callers MUST provide topology or an explicit confirmations mode; providing neither throws (programmer misuse, every environment). Defaults: embedded → "wait", remote → "two-phase"; a remote adapter opting into "wait" does so explicitly and owns its transport-timeout story.
  • The WebMCP adapter remains fixed two-phase (09).
  • Pending waits MUST settle deterministically on shutdown: toolset.dispose() aborts its in-flight wait-mode waits (the pending CONFIRMATION_REQUIRED result is returned as-is); registry.dispose() already expires pending records. Pending confirmations never outlive the registry/session contract.
  • 09 documents transport-timeout implications and recovery-after-reconnect (re-snapshot; a pending confirmation from the previous connection is retried by id or expires by TTL).

Consequences

  • Breaking (Draft APIs): policy onInvoke context (inputeffectiveInput, new onAuthorize); createAgentToolset now requires topology or confirmations. Migration notes above; no Stable API exists yet.
  • New error code: INVOCATION_CONFLICT (closed enum grows by spec change, as required).
  • New limits: observation concurrency ×3, maxPendingConfirmations; all fake-clock-testable.
  • Traceability: every correction carries requirement IDs in spec/conformance.json; the error matrix is machine-checked from spec/error-matrix.json against the implementation (scripts/check-conformance.mjs, wired into CI).

Specification + v0.1 implementation, published on npm as 0.x. Nothing is Stable yet.