Who signs what, and who can do what. Identity is metadata; keys live in storage; tokens carry attenuated scopes from an IDP; authorization checks requirements against held scopes at every boundary. The four modules compose into one sentence: an identity holds a role ceiling, a token narrows to a scope floor, a boundary demands scopes, PKI signs the crossing that records the call.
Three layers flow downward, each narrower than the last:
Roles ceiling Defined on the Identity. Maximum capabilities an actor could ever hold.
│
▼ IDP issues a token
Scopes floor Defined on the token. What this session actually asked for and was granted.
Token requests validated against roles — cannot request what roles forbid.
│
▼ Boundary executes
Requirements Declared on the boundary. Scopes every caller must hold to proceed.
authorize(identity:, requires:) checks required.all? { |s| held.include?(s) }.
Any gap at the authorization step raises Authorization::Denied with both the required and held lists for audit.
class Identity
attr_reader :id, :name, :roles, :type, :code_version, :token, :scopes
end
# Example — a boundary
Wanderland::Identity.new(
id: "boundary:repo_list",
name: "RepoList",
roles: [:boundary],
type: :service,
scopes: [:read]
).freeze
# Example — a user session with a token
Wanderland::Identity.new(
id: "alice",
name: "Alice",
roles: [:read, :write, :admin],
type: :human,
scopes: [:read, :write], # attenuated from roles
token: "<jwt>"
)
id is the only PKI reference — :pki:keys:{id} is where the keypair lives. Multiple runtimes can share an identity struct while storage mounts decide what key material backs it (dev: in-memory sqlite with auto-generated keys; prod: resolver tags like !SSM / !Vault populate :pki: at boot with real key material).
claims returns a JWT-ready hash; human?, service?, scope?(:name) are the query helpers.
Wanderland::Activities::PKI is the whole API — a functional module over storage, no singleton.
Wanderland::Activities::PKI.generate(storage, key_name, algorithm: :rsa)
Wanderland::Activities::PKI.sign(storage, key_name, canonical_string) # → base64 sig
Wanderland::Activities::PKI.verify(storage, key_name, canonical_string, sig) # → true/false
Wanderland::Activities::PKI.demote(storage, key_name) # remove :sign scope
Wanderland::Activities::PKI.key_exists?(storage, key_name)
Crossing#canonical handles request-time signing.sign scope appends a record to :pki:keys:{name} rather than mutating — the full lifecycle is in the append log.# Inside the engine, before a boundary executes
required = boundary.requirements # e.g. [:read, :sign]
held = identity.scopes # e.g. [:read]
raise Authorization::Denied unless required.all? { |s| held.include?(s) }
Every check — granted or denied — records a crossing. Denials land as :signals:stop:denied:<requirement> (see Flow Control); downstream boundaries with matching when: catchers handle recovery or escalate.
The IDP's YamlStore loads identities from YAML and issues tokens by attenuating the requested scopes:
IDP.issue(name: "alice", scopes: [:read, :write])
look up "alice" in the YAML store
reject any requested scope not in alice.roles ← the ceiling check
sign the token with alice's key via PKI.sign(storage, "alice", canonical_claims)
return { token, scopes } (attenuated)
Requesting a scope your roles don't permit fails at issuance, not at authorization. The token carries only what the ceiling allowed.
Keep identity metadata-only. No public_key, no private_key fields on the struct. The id is the sole PKI reference. If you need to share key material across runtimes, mount different storage — not different identities.
Canonicalize before signing. Never .to_s a hash and sign it — Ruby's hash iteration order is insertion-based, but the standard for canonical-form signatures is sorted keys + JSON. Crossing#canonical is the one canonicalizer; use it or match its output byte-for-byte.
Scope check uses all?. Every required scope must be held. Holding :read when the boundary needs :read, :sign is a denial — not a partial pass.
Demote, don't rotate in place. Removing a capability writes a new key record; the old record is preserved. The append-only log shows when the demotion happened and who signed it.
Let signing be soft at the edges. Crossing#sign auto-generates a key on first use if none exists — dev and test get ephemeral keys for free. Production provisions real keys into :pki: at boot via resolver tags (!SSM, !Vault). Forged or unsigned crossings still enter the context (audit trail preserved); they're invisible through .signed trust filters (see Merkle DAG).
Use boundary identity, not app identity, where it matters. An application-wide identity signs every crossing as the app; declaring a per-boundary identity (via identity: on the DSL) means each boundary's signature answers "what code did this," not just "what process." The seal boundary signs as engine:seal specifically so audit auditors know the seal is framework, not request.
The deeper nodes for each topic in this category:
required.all? as the gate. Trace recording for every granted/denied check.id, name, roles, type, scopes, token. Query helpers. Where identity surfaces across boundary registration, crossings, authorization, IDP, PKI, and crossing signing.generate/sign/verify/demote/key_exists?. Keys as records at :pki:keys:{id}.wanderland.dev