RFC 0011: Capability tokens for write authorization
Summary
Define an optional contract by which a write to a .uw.md file carries a short-lived, scope-limited capability token. The token declares "this writer is authorized to write section X at stage Y for deal Z, until time T," signed by a coordinator service. The applyEdit API gains an optional EditContext.capability_token field; the editor verifies the token before accepting the write. This is opt-in, not part of the open standard's everyday flow — it exists for orchestrator-bound deployments (lending platforms, agent fleets) where authorization has to be enforced at the file API layer rather than at the network layer.
Motivation
The default UW Markdown trust model is "the file is the protocol." Any party with the file can write any section, with the supersede chain providing audit history. POL-NN codes from RFC's Phase 3 catch mismatches between _meta.actor and a per-section policy table.
That model breaks down in three concrete deployments:
- Multi-tenant agent host. A platform runs hundreds of
agent/L2instances against thousands of deals. The static policy table says "agent/L2 may writenoi_model," but cannot say "this specific agent/L2 instance may write deal D's noi_model and no other deal's." Today the host enforces that out-of-band — via API auth at the network layer. The file itself has no visible record of the authorization decision. - Sponsor / lender / appraiser hand-off. Three external parties each get write access to one section of a file for a 24-hour window. The current spec has no way to encode that scope. Either each party is fully trusted (insecure) or the orchestrator reassembles writes from sidecar files (loses the single-file-is-the-protocol property).
- Forensic reconstruction. When something goes wrong — a wrong value lands in a file, or an unauthorized actor writes — the investigator wants to know "who authorized this write, and was the authorization in scope?"
_meta.actorsays who claims to have written it; a capability token says who authorized it.
Without a spec-defined capability token shape, every adopter that needs this builds their own, breaking interop and complicating forensic tools.
Proposed change
Token shape
A capability token is a small JWS-Compact encoded object:
// Decoded JWT payload
{
"iss": "https://coordinator.example.com", // issuer
"sub": "agent/L2/instance-abc-123", // who is authorized
"aud": "uwmd-edit", // audience scope
"deal": "DEAL-2026-0042", // deal_id binding
"sections": ["noi_model", "debt_structure"], // sections allowed to write
"stages": ["screening", "term_sheet"], // stages the token is valid at
"ops": ["section_supersede", "frontmatter_set"], // edit ops permitted
"iat": 1745798400, // issued at (unix seconds)
"exp": 1745802000, // expiry (unix seconds)
"jti": "01J9XYZ7K3M2…" // unique token id (for revocation tracking)
}Header:
{ "alg": "ed25519", "kid": "coord-2026-04", "typ": "JWT" }Signed with the coordinator's private key (Ed25519 default; ES256 permitted). Verifier holds the coordinator's public key in a configured KeyStore (same shape as RFC 0010).
applyEdit integration
The EditContext interface gains:
interface EditContext {
// existing fields…
/** A capability token authorizing this edit. Required only when
* the editor is configured with a `capabilityVerifier`. */
capability_token?: string;
}
interface ApplyEditOptions {
// existing fields…
/** When supplied, every write MUST present a token that the
* verifier accepts. Edits without a token, or with a rejected
* token, fail with EditError code POL-03. */
capabilityVerifier?: CapabilityVerifier;
}
interface CapabilityVerifier {
verify(token: string, ctx: { deal_id: string; section: string; stage: DealStage; op: EditOperation['kind'] }): Promise<{
ok: true;
sub: string; // matched against _meta.actor
jti: string;
} | {
ok: false;
reason: 'expired' | 'wrong_audience' | 'wrong_deal' | 'wrong_section' |
'wrong_stage' | 'wrong_op' | 'bad_signature' | 'unknown_kid' | 'malformed';
}>;
}Verification rules:
- The token's
expmust be in the future at edit time. - The token's
dealmust equal the file'sdeal_idfrontmatter. - The target section must appear in
sections. - The current
deal_stagemust appear instages. - The edit's operation kind must appear in
ops. - The token's
submust match the prospective_meta.actor. - The signature must verify against the configured public key for
kid.
Token persistence
By default the token itself is not stored in the resulting block — only its jti is recorded in _meta.notes (or, post-RFC-0009, in _meta.provenance.notes) using the convention "capability:<jti>". Adopters who want full forensic recall MAY configure the editor to persist the entire token in a sidecar audit log (<file>.audit.jsonl), but the token is intentionally NOT embedded in the file because it would expose information about the coordinator service.
New error code
| Code | Severity | Trigger |
|---|---|---|
POL-03 | error | Capability verifier rejected the token. The reason from verify() is included in the issue's details. |
CLI
uwmd edit gains:
uwmd edit <file> <op-spec> --capability-token=<jwt> [--coord-key=<keyfile>]Where --coord-key points at the coordinator's public key for verification. Default behavior (no flag) is the existing model — no capability check.
Compatibility analysis
- Existing
.uw.mdfiles — unaffected. - Tier-1/2/3/4 implementations — capability checking is opt-in. An implementation that doesn't enable it behaves exactly as today.
- Tier-2 editors that opt in — gain a hard dependency on a coordinator service and a public key for verification.
- Modules — unaffected.
This RFC adds a new conformance capability tier: capability-aware. Hosts claiming this MUST pass the capability fixtures.
No deprecation path. Additive.
Conformance impact
New fixtures (under conformance/tier-2-editor/capability-fixtures/):
01-valid-token-accepts/— well-formed token; edit accepted.02-expired-token-rejects/—expin past; expectsPOL-03 (expired).03-wrong-section/— token scopesnoi_model; edit targetsrisk_assessment; expectsPOL-03 (wrong_section).04-wrong-deal/— token'sdealdoesn't match file'sdeal_id; expectsPOL-03 (wrong_deal).05-actor-mismatch/— token'ssubisagent/L2/inst-A; edit's_meta.actorisagent/L2/inst-B; expectsPOL-03 (actor_mismatch).06-bad-signature/— token signed with a different key; expectsPOL-03 (bad_signature).
Reference implementation
- Files affected:
packages/uwmd-core/src/protocol.ts—CapabilityVerifier,EditContext.capability_token,POL-03.packages/uwmd-core/src/editor.ts— capability check before every write.packages/uwmd-capability/(new package, optional) — referenceCapabilityVerifierimplementation backed by Web Crypto / Node crypto, JWT decoder, file-based public-key store.packages/uwmd-core/src/cli.ts—--capability-token/--coord-keyflags.spec/UW_PROTOCOL_v1.md— new §XI "Capability tokens (optional)."
- API surface: additive. Default-imported
@uwmd/coredoes not depend on capability code. - Test plan: sign a token with a known Ed25519 key fixture, verify under each rejection branch, property-test that "any permutation of token claims that diverges from edit context yields a specific
reason."
Alternatives considered
Network-layer auth only (status quo). Rejected because it leaves no record in the file of authorization decisions. A file exported from one platform to another loses its provenance record.
Embed full token in
_meta.signatureper RFC 0010. Conflates "who signed the content" with "who was authorized to write." Two different concerns (one is non-repudiation of authorship; the other is policy enforcement at write time) deserve two mechanisms.Inline a token field in
_metaitself. Rejected on disclosure grounds — tokens often encode coordinator infrastructure that shouldn't travel with the file.Use full OAuth 2 token introspection at edit time. Far too heavy. Self-contained JWT-style tokens with offline verification are the right shape; the coordinator is the issuer, not a per-edit gatekeeper.
Macaroon-style tokens with caveats. Macaroons are arguably a better fit (the "ops" / "sections" caveats compose naturally), but the ecosystem support is thin and JWT is what every adopter already knows.
Unresolved questions
- Revocation. JWTs are notoriously hard to revoke. A short
exp(default proposal: 1 hour) makes revocation a non-issue most of the time, but agents writing a long sequence of edits need a refresh story. Probably: the coordinator issues a fresh token every N edits or M minutes, the agent's edit loop refreshes transparently. Not part of the spec; coordinator-implementation detail. - Token storage policy. When operators enable persistent tokens (sidecar audit log), retention is on them. Spec recommends 90 days default but does not normatively require any retention.
- Multi-issuer scenarios. A file could be edited under tokens from different coordinators (sponsor's, lender's). The verifier must hold all relevant public keys. No spec change needed; just a configuration concern. Worth a non-normative note.
- Token binding to a specific block content_hash. Stronger guarantee: "this token authorizes edit producing exactly this hash." Rejected for v1 — too rigid; agent retries become impossible. May revisit if a deployment specifically asks.
Prior art
- JWT (RFC 7519) — the obvious base layer. We use it.
- Biscuit tokens (biscuitsec.org) — attenuable capability tokens. Considered for the "agent gets a short-lived token, attenuates further before passing to a sub-agent" pattern; deferred as more complexity than needed for v1.
- GitHub fine-grained PATs — same shape: scoped to a resource, scoped to operations, time-limited. Inspired our claim choice.
- AWS STS session tokens — similar shape with role assumption. We don't model role assumption explicitly; the coordinator encapsulates it.