Contracts first (T02–T04): approval claim schema with issuer, freshness,
and binding digest; local transactional outbox wire; load-bearing cadence
as heartbeat or reconciliation (layer.yaml declared).
Then the object (T06–T08): SQLite closed state machine, CAS supersession,
distinct-approver fail-closed, revocation without holder cooperation,
outbox insert in the same transaction. Tests fail the mutation when
emission fails, and revoke while the drain sink is down.
Introspection GET /v1/approvals/{id}/claim is a PIP fact, not a decision.
No public consume (T05 waits on GH-WP-0002-T06). Canon T-06 coverage for
wrong binding, expiry, revoke, and supersede.
FLEX-WP-0017 T03 is unblocked on this object; T05 remains blocked only on
consumption ordering.
Assistant: grok
Assistant-Session: 01a04ceb-2057-7e20-b0f9-c282964d5dd9
4.4 KiB
Approval claim contract
Schema: ../schemas/approval_claim.schema.json
Version: 0.1
Issuer: approval-engine
Consumer: access-engine (flex-auth until the governed rename)
This is the input claim access-engine consumes under statute §6.2. It is a
fact about an approval object. It is not a decision. An implementer can
satisfy this document without reading this engine's source.
Yields to the Taxonomy request-claim schema (statute §17) when that artifact exists and is assented. This local shape is not permanent.
Fetch
GET /v1/approvals/{id}/claim
Fail-closed when this engine's store is unavailable (HTTP 503). An audit-core
outage does not affect this read.
There is no /v1/check, no /authorize, and no field named effect,
decision, allow, or deny. If a response contains those, it is out of
contract.
What the claim carries
| Field | Why |
|---|---|
approval_id |
Reconstructable from the decision record. |
binding.digest |
Distinguishes approved from approved for this exact request. |
binding.pdp_digest |
Optional. When recorded at issue, compare to NewDecisionBinding.request_digest. |
issuer |
Always approval-engine. |
freshness |
So the PDP can state a deadline for this input class (§9.7.2), not a single fiction covering every source. |
valid_now |
Current-state predicate. Not permission. |
reason_code |
Why valid_now is false, when it is. |
valid_now is true only when all of:
- enough distinct authenticated approvers have been recorded;
- now is inside
validity.not_before…validity.expires_at; - the object is not consumed, superseded, revoked, or expired.
Holding a claim with valid_now: true is not authority to act. It is one
input the decision point weighs.
Canonical binding digest
The native digest is:
sha256: + hex( SHA-256( canonical_json({action, actor, principal, purpose, target}) ) )
canonical_json is UTF-8 JSON with sorted keys at every object level and no
insignificant whitespace (separators=(',', ':')). target is an object; its
keys are sorted too.
Wrong-action, wrong-target, and wrong-scope are distinguishable because they change that JSON and therefore the digest. A decision rendered against approval A for request R cannot be replayed for request R' if the consumer compares digests.
Mapping from a flex-auth CheckRequest
| Claim binding | CheckRequest |
|---|---|
action |
action |
target |
resource (object) |
actor |
subject.id |
principal |
subject.attributes.principal if present, else subject.id |
purpose |
context.purpose |
Go's json.Marshal of a CheckRequest is not this canonical JSON (field
order and omitempty differ). Do not hash a CheckRequest with this function
and expect it to equal NewDecisionBinding.request_digest.
When the issuer recorded the PDP digest at issue time, it is in
binding.pdp_digest. Prefer that comparison at decision time:
claim.binding.pdp_digest == decision.binding.request_digest
and still require claim.approval_id to match the approval named on the
request. Native binding.digest remains the identifier of this engine's
binding, and is what T-06 uses when no PDP digest was recorded.
Freshness
Default TTL is 30 seconds from observed_at. not_after is
observed_at + ttl_seconds. A PDP that needs a different deadline for the
approval input class states it in its own provenance; it must not invent
freshness this engine did not publish.
A claim used after freshness.not_after is stale. Stale is not the same as
valid_now: false — the object may still be valid; the observation is old.
Re-fetch.
Required verification (consumer)
A production consumer of this claim, before treating it as an input, checks:
- The claim resolved from this engine; an outage fails the action closed.
issuerisapproval-engine.valid_nowis true andconsumedis false.binding.digestequals the digest of the binding the consumer computed from the proposed action, orbinding.pdp_digestequals theNewDecisionBindingdigest of that request.freshness.not_afteris still in the future.reason_codeisok.
Local fixtures, workplan ids, and prose are not this claim.
Examples
See ../examples/claim.valid.json and
../examples/claim.revoked.json.