Releases: beyond10x/mandate
Release list
Mandate 0.6.0
Wave M: a browser signs in through an external OIDC IdP, and a credential is exchanged for one
another platform accepts. Three stories, each attacked twice.
Added
- OIDC relying-party flow.
GET /v1/federation/authorizeredirects to the IdP's discoveredauthorization_endpoint
with a single-usestate, anonceand an S256 PKCE challenge, bound to the browser by a
__Host-cookie.GET /v1/federation/callbackredeems the code at the discoveredtoken_endpointwith HTTP
Basic client authentication. It requires RFC 9207isswhen the IdP advertises it, verifies
the ID token and itsnonce, and opens a session.- The session reaches the embedding application only through a single-use handoff code sent to
the connection'sreturn_uriand redeemed once atPOST /v1/federation/handoff, together with
theapp_statethe application supplied. - The client secret is read from
--client-secret-file NAME=PATH. - A relying-party connection refuses direct
/v1/federation/login. - (
story:relying-party-code-flow)
- RFC 8693 token exchange at
/oauth/token.- A Mandate access credential is exchanged for a credential for a resource server whose
allowed_exchange_sourceslists the subject's. - The target is named by id, by registered audience or by
resource. - Refusals answer
invalid_targetorinvalid_grantfrom one table, are recorded as
TokenExchangeDeniedand draw nothing. The metadata document advertises the grant. - (
story:federated-token-exchange)
- A Mandate access credential is exchanged for a credential for a resource server whose
Changed
- A tenant claim the connection's rule names that is not a JSON string is refused as
TenantClaimNotText(<type>), where before it resolved silently to no tenant
(story:federation-fixtures-rs256). - The transport gap and the roadmap state that TLS is terminated at the ingress and the hop from the
ingress to the process is plain HTTP.
Known
- An anonymous flood of authorize requests can evict a waiting browser's sign-in; rate limiting is
the ingress's (story:authorize-flood-eviction). - A slow IdP stalls every route on the single-threaded listener (
story:listener-outbound-stall). - Nothing persists: a restart loses sessions, pending sign-ins, handoff codes and credentials.
Mandate 0.5.1
Wave L: four stories in three units, each attacked twice.
Changed
ConnectionStore::enabled_for_issuermay answer connections in any lifecycle state; the crate
decides which are enabled on an issuer, reading each listed id throughconnection(id)and
falling back to the listed record, so an implementor cannot widen a decision
(story:enabled-for-issuer-filters-in-the-implementor).
Fixed
- The federation destination guard folds at most one trailing dot, and only on a name:
localhost..,
127.0.0.1.,[::1.]and any host with an empty label (.localdomain,keys..localdomain) are
refused. The control-plane loopback guard folds a trailing dot on names only too, so both refuse
127.0.0.1.. A 12,168-decision sweep moved 685 decisions, all from admitted to refused
(story:federation-guard-trailing-dots,story:bracketed-literal-trailing-dot). - A login whose session opening is refused keeps no security-epoch record it wrote on the way, and a
refused--connectionor--clientdocument seeds nothing (story:control-plane-multi-fold-writes). - A redelivered
FederationConnectionCreatedfor a held connection is an idempotent no-op, so a
disabled connection can no longer come backEnabledbeside its own record and refuse another
organization's logins and registrations (story:enabled-for-issuer-filters-in-the-implementor).
Known
- Two connection documents may state one
external_principal_id; the second link is dropped
(story:seeding-repeated-external-principal-id). - A numeric IPv4 shorthand with a trailing dot (
127.1.) is admitted and cannot be fetched; it fails
closed (story:numeric-shorthand-trailing-dot).
Mandate 0.5.0
Waves I, J and K: nine stories, each attacked twice. The clause count moves for the first time since
wave D: 191 clauses (one split in two), 85 decided on the real path.
Changed
- Breaking.
mandate_sts::IdentityAllocatorrequiresreserve_credential_id,
commit_credential_idandrelease_credential_id; there are no defaults. A reservation holds —
the next draw skips it — and a released identity an overtaking draw passed stays a gap
(story:sts-refusal-draws-nothing). - Breaking.
IncrementSecurityEpochneeds the newTargetTenancyport beside
SecurityEpochWrite(story:identity-tenant-containment). - The event log is pinned at
0.3.0(eventlog-core,eventlog-sqlite); nothing calls it yet
(story:eventlog-repin-0-3-0). - An obligation clause row may carry
decided_in: <workspace member>naming the crate whose test
decides it. The registry refuses the entry's own crate, a non-member,decided_inoff a clause
row, an empty or joiner-only clause, one test id under two kinds, paths or doubles, and a double
whose module path is not public.AuthorizePublicClient's "STS code issuance/narrowing" is split:
issuance is decided inmandate-sts; narrowing is deferred tostory:agent-authority-kernel
(story:cross-crate-clauses).
Fixed
- A just-in-time login records the
mandate.identity.Principalits provisioning event declares, and
a provisioning either fold refuses keeps neither (story:jit-principal-record). - An issuer's own origin compares two address literals as addresses (
[::1]and
[0:0:0:0:0:0:0:1]are one origin), andoriginreads an authority the way the fetcher does:
nothing but:after], only an IPv6 address inside brackets, digits-only ports. A JWKS
destination[addr]x:Pis no longer admitted at the default port and fetched onP
(story:folded-is-not-an-address-fold). register_resource_serverrefuses a profile whosemax_ttl, added to3000-01-01T00:00:00Z,
leaves the four-digit year, asProfileUnadmitted; both issuance commands and the
authorization-code redemption refuse, asExpiryUnbounded, any expiryinstant::atwould render
in a form the crate cannot read back — past9999-12-31T23:59:59Zor before
0000-01-01T00:00:00Z(story:sts-lifetime-bounds).- A plaintext issuer's loopback check reads each host form by its own parser: userinfo is refused,
a bracketed host must be IPv6, an unbracketed host may not contain:, at most one trailing dot is
folded, and a spelled port is digits only.http://localhost:80@evil.exampleis refused
(story:control-plane-loopback-fold). IncrementSecurityEpochrefusesTenantMismatchfor a target any record places in another
organization. The check is a read before the compare-and-set and not atomic with it; that is
decision-blocker:epoch-atomicity's (story:identity-tenant-containment).- Registration refuses a second organization's tenant rule on one issuer when one proof could
satisfy both — a rule on a different claim name — asTenantResolutionUnadmitted
(story:federation-rule-disjointness). - A refused STS redemption or self-contained issuance draws nothing from the secret source or the
identity allocator; a redemption decides every refusal before it mints (story:sts-refusal-draws-nothing). - The login-road test lane takes its child's address from the child's own
listening online, and
its concurrent copies no longer start copies of their own (story:road-lane-child-prints-address).
Known
- A bracketed issuer literal with a trailing dot (
[::1.]) is admitted as the issuer's own origin
and cannot be fetched; it fails closed (story:bracketed-literal-trailing-dot). DisableOAuthClient's "disablement cannot stop the issuance of new authorization codes to it" is
decided by nothing and deferred todecision-blocker:epoch-atomicity.
Mandate 0.4.0
Three defects the previous release's own adversary passes measured and filed, fixed and gated. The
clause count is unchanged at 190 clauses and 83 decided on the real path: none of this touches a
clause, and the number stands on decision-blocker:guards.
Changed
- Breaking.
LinkStoredeclares one required method,records_on_key, answering every record on
an external key in every lifecycle state.linkandkey_is_heldmoved to a newLinkResolution
trait with a blanket implementation over everyLinkStore, so the record that holds a key and
whether a key is held are decided by this crate and cannot be answered by an implementor. Any
out-of-crateLinkStoreimplementinglinkmust move torecords_on_key; a blanket impl cannot
be overridden, which is the point — adversary pass 2 ofstory:link-absent-discriminatesbuilt a
store that satisfied every stated requirement and provisioned around a revocation. services/control-plane'sDeployment::key_holds_no_recordpre-check is removed with its call
site. The condition is decided in the library, in one place, and the adapter's own verifier call on
that path goes with it — one fewer port call per refused provisioning attempt.
Fixed
ProvisionExternalPrincipalrefusesExternalKeyExistsfor a key any record holds in any
lifecycle state, so a login afterUnlinkExternalPrincipalno longer provisions around the
domain's only federated revocation by minting a newPrincipalIdfor the same external subject
(story:link-absent-discriminates).Projection::linkprefers the smallestLinkedrecord and
falls back to the smallest record in any state only when the key has none, so
authenticate_federation,LinkConflictandProjection::conflicts()are unchanged — verified
over eight lifecycle-state masks × eight append orders.UreqJwks::admitsfolds the destination host once and hands one name to all three comparisons, so
a host and its absolute spelling get one answer and127.0.0.1.can no longer list its way past
the SSRF containment (story:host-spelling-folded). The class had seven members beyond the one
reported. Measured over 12,168 base→head decisions: 241 move refused → admitted and every one
leaves through the issuer's own-origin comparison; 716 move admitted → refused and every one is an
address literal spelled with a trailing dot. Nothing becomes reachable through the containment
branch that was not reachable before.- Every execution of a control-plane test binary gets its own scratch root, and each run sweeps the
roots of runs whose process id is no longer live, so the parent is bounded by the live runs
(story:per-run-test-scratch).CARGO_TARGET_TMPDIRis resolved at compile time, so two
concurrent copies of one lane clobbered each other's flag documents: 45, 14 and 29 failures
measured across three lanes before, 0 after, with 3,780 cases at 4-way concurrency clean.
Added
decision-blocker:async-runtimeandUNMAPPED-RUNTIMEindocs/architecture/unmapped.md. ADR
0009 — event-sourced persistence through the organizationeventlogkit, accepted 2026-09-18 — is
implemented by nothing: the kit is pinned and named by two manifests, no Rust file references it,
and every fold is hand-written and synchronous. One admission stands between the ADR and the code
and was never taken — the kit'sEventStoreis async and this workspace admits no runtime, which
wave 3 recorded as stop condition S1 on 2026-09-18 and deferred.story:domain-folds-over-the-kit
is blocked on it;story:eventlog-repin-0-3-0moves the pin to the tag released today and says
plainly that it buys nothing until the admission is taken.- Five stories filed from what the adversary passes found and this release did not fix, each
carrying the red case that found it verbatim in its own body:
story:control-plane-loopback-fold,story:linked-event-method-unenforced,
story:road-lane-child-prints-address,story:folded-is-not-an-address-fold,
story:enabled-for-issuer-filters-in-the-implementor. crates/mandate-conformance's completeness guard scans port declarations rather than one
implementation's overrides, so a defaulted port method is no longer invisible to it. Turning it on
surfaced four methods the override scan had never seen, two of them defective:
CredentialResolution::cachedand::cached_atansweredNoneconsulting nothing, which is the
armed-unreached the guard exists to catch.
State at this release
task check |
exit 0 — 250 suites, 1,920 tests passed, 0 failed |
| named mutants | 11, each killed by the target it names |
| conformance | 166 scenarios, error 0 |
| obligations | 190 clauses · 83 real · 21 double-only · 86 deferred — unchanged |
| coverage map | 292 elements, 0 unexplained |
Tagged on 92fc026, the merge of #20. The same main also carries #21, which corrected the public pages, and #22, which rewrote the execution handoff.
This is a development milestone, not a deployable service. contracts/use-cases/federated-login.json lists what is not true yet, and the first entry is that there is no transport security: every proof, session identifier, authorization code and credential on this road crosses the network in the clear.
v0.2.0
Mandate 0.2.0 — contract creators, generated shapes, event-sourced tenancy, an event harness
The first release of the ESS-to-implementation drift-protection programme (wave E1).
Specification (systems/mandate, ESS 0.26.0): 16 creators declared with creates: and instance:; every creating event carries its record with truthful sources; 34 moving commands declare a wrong-state outcome; AuthenticateFederation creates the Session and returns session_id, organization_id, principal_id, epochs, expires_at. Synthesis: 84 → 132 scenarios, refusals 106 → 59, every refusal named.
Implementation: mandate-contract (generated Rust shapes for all 72 events, 59 inputs, 24 responses, 11 errors, 36 entities; cargo xtask contracts byte-compares them); mandate-testkit::contract (schema, emission-count and payload-source checks against the compiled model); mandate-model tenancy and resource commands as decide + apply + fold with replay proofs; mandate-conformance skeleton and mandate-conform binary; cargo xtask adopt.
Conformance at this release: 0 of 132 scenarios executed against the implementation (no conformance target yet). Entities the log cannot rebuild: 12 (was 14). Contract-versus-implementation drift the wave surfaced and routed: a consumed authorization code returns 502 where the contract says 409; four idempotent-accept records where the contract says 409.
Not in this release: a served route, a real signature verifier, a durable event-log adapter, credential issuance and code redemption at STS, the customer-facing login flow end to end.
Full changelog: CHANGELOG.md, [0.2.0].
Mandate 0.1.0
The first tagged release of Mandate: the state of main after three implementation waves of the ten-wave forecast, with a changelog that backfills the batches that led here as pre-releases (v0.1.0-alpha.1 … v0.1.0-alpha.4).
What runs
| Capability | Crate |
|---|---|
mandate.authorization.Check decided over the graph and policy ports: context binding, one graph read and one policy read folded through deny precedence with the requested authority scope and the applicable ceilings, challenges assembled; nothing a port cannot answer becomes an allow |
mandate-authz |
Federation: connection registration, explicit linking, first-login provisioning, authentication, and the non-consuming AuthorizePublicClient validation (S256 challenge form decided by decoding, registered client, exact redirect, tenant-bound target, session, constant-time state and nonce) |
mandate-federation |
| Session security generations, snapshots and refresh that denies a stale epoch | mandate-identity |
| Tenancy and resource topology folds with a compile-time persisted-value boundary | mandate-model |
| Graph and policy ports over doubles; a revision-bound read that denies a revoked grant at every floor | mandate-graph, mandate-policy |
| The 74 accepted canonical types with a machine-checked inventory | mandate-types, mandate-token, mandate-proto |
mandate pkce-challenge: the S256 challenge for a verifier read from stdin, never echoed |
bins/mandate |
| A contract of 72 events, 59 commands, 110 types and 36 entities, regenerated and byte-compared; every fold input declared on an event | systems/mandate, generated/ |
A gate that reads its own document deliverables (cargo xtask documents) |
xtask/ |
Validation: task check on the wave-3 head: exit 0, 109 test targets, 611 tests passed, 0 failed; dependency boundaries, 59 command obligations, 50 contract scenarios with source-hash verification, deterministic ESS regeneration, cargo deny, planning-store validation, and the document step all green. Twenty-four adversary passes across the three waves; every finding has a recorded outcome on its story.
Not in this release
- No served route: every binary still refuses
serve. - No real signature or JWKS verifier: the port ships with a fixture double (
story:signing-and-verification). - No durable event-log adapter: the kit is wired, every fold is in-memory.
- No credential issuance or authorization-code redemption at STS (
story:credential-profiles,story:oauth-integration). - Fourteen entities have no creation event yet and cannot be rebuilt from the log (
story:declared-writers). - The customer login flow does not run end to end yet; see
docs/architecture/federated-login.mdfor the design and the road.
Pull requests in this release
- #4 — the ten-wave plan and the integration-batch delivery rule (
v0.1.0-alpha.1) - #5 — wave 1: canonical types, runtime decision dossier (
v0.1.0-alpha.2) - #6 — wave 2: session epochs, tenancy topology, federation linking, graph and policy ports (
v0.1.0-alpha.3) - #7 — wave 3: Check decisions, public-client validation, fold-ready events, the document gate (
v0.1.0-alpha.4) - #8 — the changelog
Full details: CHANGELOG.md.