Skip to content

Releases: beyond10x/mandate

Mandate 0.6.0

Choose a tag to compare

@b10x-bot b10x-bot released this 24 Sep 00:13
95e0a4b

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/authorize redirects to the IdP's discovered authorization_endpoint
      with a single-use state, a nonce and an S256 PKCE challenge, bound to the browser by a
      __Host- cookie.
    • GET /v1/federation/callback redeems the code at the discovered token_endpoint with HTTP
      Basic client authentication. It requires RFC 9207 iss when the IdP advertises it, verifies
      the ID token and its nonce, and opens a session.
    • The session reaches the embedding application only through a single-use handoff code sent to
      the connection's return_uri and redeemed once at POST /v1/federation/handoff, together with
      the app_state the 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_sources lists the subject's.
    • The target is named by id, by registered audience or by resource.
    • Refusals answer invalid_target or invalid_grant from one table, are recorded as
      TokenExchangeDenied and draw nothing. The metadata document advertises the grant.
    • (story:federated-token-exchange)

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

Choose a tag to compare

@b10x-bot b10x-bot released this 23 Sep 13:16
d55a951

Wave L: four stories in three units, each attacked twice.

Changed

  • ConnectionStore::enabled_for_issuer may answer connections in any lifecycle state; the crate
    decides which are enabled on an issuer, reading each listed id through connection(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 --connection or --client document seeds nothing (story:control-plane-multi-fold-writes).
  • A redelivered FederationConnectionCreated for a held connection is an idempotent no-op, so a
    disabled connection can no longer come back Enabled beside 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

Choose a tag to compare

@b10x-bot b10x-bot released this 23 Sep 08:20
52de770

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::IdentityAllocator requires reserve_credential_id,
    commit_credential_id and release_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. IncrementSecurityEpoch needs the new TargetTenancy port 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_in off 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 in mandate-sts; narrowing is deferred to story:agent-authority-kernel
    (story:cross-crate-clauses).

Fixed

  • A just-in-time login records the mandate.identity.Principal its 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), and origin reads an authority the way the fetcher does:
    nothing but : after ], only an IPv6 address inside brackets, digits-only ports. A JWKS
    destination [addr]x:P is no longer admitted at the default port and fetched on P
    (story:folded-is-not-an-address-fold).
  • register_resource_server refuses a profile whose max_ttl, added to 3000-01-01T00:00:00Z,
    leaves the four-digit year, as ProfileUnadmitted; both issuance commands and the
    authorization-code redemption refuse, as ExpiryUnbounded, any expiry instant::at would render
    in a form the crate cannot read back — past 9999-12-31T23:59:59Z or 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.example is refused
    (story:control-plane-loopback-fold).
  • IncrementSecurityEpoch refuses TenantMismatch for 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 — as TenantResolutionUnadmitted
    (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 on line, 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 to decision-blocker:epoch-atomicity.

Mandate 0.4.0

Choose a tag to compare

@b10x-bot b10x-bot released this 22 Sep 23:59
92fc026

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. LinkStore declares one required method, records_on_key, answering every record on
    an external key in every lifecycle state. link and key_is_held moved to a new LinkResolution
    trait with a blanket implementation over every LinkStore, 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-crate LinkStore implementing link must move to records_on_key; a blanket impl cannot
    be overridden, which is the point — adversary pass 2 of story:link-absent-discriminates built a
    store that satisfied every stated requirement and provisioned around a revocation.
  • services/control-plane's Deployment::key_holds_no_record pre-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

  • ProvisionExternalPrincipal refuses ExternalKeyExists for a key any record holds in any
    lifecycle state, so a login after UnlinkExternalPrincipal no longer provisions around the
    domain's only federated revocation by minting a new PrincipalId for the same external subject
    (story:link-absent-discriminates). Projection::link prefers the smallest Linked record and
    falls back to the smallest record in any state only when the key has none, so
    authenticate_federation, LinkConflict and Projection::conflicts() are unchanged — verified
    over eight lifecycle-state masks × eight append orders.
  • UreqJwks::admits folds the destination host once and hands one name to all three comparisons, so
    a host and its absolute spelling get one answer and 127.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_TMPDIR is 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-runtime and UNMAPPED-RUNTIME in docs/architecture/unmapped.md. ADR
    0009 — event-sourced persistence through the organization eventlog kit, 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's EventStore is 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-0 moves 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::cached and ::cached_at answered None consulting 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

Choose a tag to compare

@b10x-bot b10x-bot released this 19 Sep 02:26
79cb71a

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

Choose a tag to compare

@b10x-bot b10x-bot released this 18 Sep 23:07
874a74f

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.md for 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.