Implement the engine spine: claim, outbox, machine, API
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
2026-08-29 12:52:49 +02:00
|
|
|
# Approval claim contract
|
|
|
|
|
|
|
|
|
|
**Schema:** [`../schemas/approval_claim.schema.json`](../schemas/approval_claim.schema.json)
|
|
|
|
|
**Version:** 0.1
|
|
|
|
|
**Issuer:** `approval-engine`
|
2026-09-06 08:02:48 +02:00
|
|
|
**Consumers:** `access-engine` as PDP (`flex-auth` until the governed rename);
|
|
|
|
|
PEP-shaped consumers as step 1 of the `GH-DEC-2026-003` consumption path
|
Implement the engine spine: claim, outbox, machine, API
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
2026-08-29 12:52:49 +02:00
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
2026-09-06 08:02:48 +02:00
|
|
|
**This is the step-1 artifact.** `GH-DEC-2026-005` (2026-09-06) confirms that
|
|
|
|
|
`GET /v1/approvals/{id}/claim` serves this claim on the `GH-DEC-2026-003` path,
|
|
|
|
|
that no other envelope is required or may be served from this endpoint, and
|
|
|
|
|
that a PEP validates across **two** artifacts: this claim for the approval
|
|
|
|
|
fact, and the step-2 flex-auth `DecisionEnvelope` for exact `CheckRequest`
|
|
|
|
|
match and the policy package/version pin. Each artifact is validated against
|
|
|
|
|
the layer that owns its data; a PIP does not republish the PDP's decision. The
|
|
|
|
|
"Required verification" section below is the supported path for both consumers.
|
|
|
|
|
|
|
|
|
|
The authority for the approval fact is this engine — `issuer:
|
|
|
|
|
"approval-engine"`. State Hub is a read model and issues no approval; a
|
|
|
|
|
consumer requiring a `state-hub` authority on this claim fails closed against
|
|
|
|
|
every correctly issued response. `GH-DEC-2026-005` struck that requirement
|
|
|
|
|
explicitly.
|
|
|
|
|
|
Implement the engine spine: claim, outbox, machine, API
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
2026-08-29 12:52:49 +02:00
|
|
|
Yields to the Taxonomy request-claim schema (statute §17) when that artifact
|
|
|
|
|
exists and is assented. This local shape is not permanent.
|
|
|
|
|
|
|
|
|
|
## Fetch
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
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:
|
|
|
|
|
|
|
|
|
|
1. enough distinct authenticated approvers have been recorded;
|
|
|
|
|
2. now is inside `validity.not_before` … `validity.expires_at`;
|
|
|
|
|
3. 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:
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
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` |
|
|
|
|
|
|
State pdp_digest explicitly; decline to publish a vocabulary mapping
flex-auth asked whether this engine should publish an action/target
mapping between the claim binding's vocabulary (secrets.kv.destroy,
{"id": "lane-openbao-root"}) and a policy package's (destroy, lane:...),
since their package makes no cross-check that a claim was approved for
the action being decided.
Answered no. A PIP asserting that one vocabulary's action means
another's would author policy semantics it does not own, over
vocabularies it does not own, and the failure mode is asymmetric: a wrong
mapping silently accepts a claim approved for a different action, which
is worse than no mapping. binding.pdp_digest is the correspondence and
sidesteps vocabulary entirely -- it compares the PDP's own digest to the
PDP's own digest, with no translation by anyone.
Implemented the part that was ours. pdp_digest was emitted only when
recorded, so a consumer could not distinguish "not issued against a
decision" from "we forgot to look". It is now always present and null in
that case, required-but-nullable in the schema, and documented as
something a PEP on a privileged lane must refuse. This engine states the
fact; enforcing the lane's policy stays with the consumer.
Both published examples were already contradicting the updated schema by
omitting the field -- the same fixture-versus-contract defect flex-auth
hit twice this week and that secrets-engine implemented. Fixed both, made
them cover the PDP-bound and unbound shapes so neither is inferred from
the other, and added tests/test_examples.py to validate every example
against the schema so the class cannot recur here. jsonschema added as a
dev dependency.
94 tests pass (6 new).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvyJPAaVCGsVheVhcCwNND
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 411227@bnt-lap001
Assistant-Session: d566f6d3-bcaf-43c3-bc5e-3ddd0f64b535
2026-09-06 09:32:11 +02:00
|
|
|
### Action and target vocabulary — there is no published mapping, by design
|
|
|
|
|
|
|
|
|
|
The table above maps *fields*, not *values*. The claim's `action` and `target`
|
|
|
|
|
carry whatever vocabulary the approval's creator used
|
|
|
|
|
(`secrets.kv.destroy`, `{"id": "lane-openbao-root", "stage": "prod"}`); a policy
|
|
|
|
|
package may use its own (`destroy`, `lane:...`). **This engine does not publish
|
|
|
|
|
a translation between them and will not.**
|
|
|
|
|
|
|
|
|
|
This is a layer boundary, not an omission. A PIP that asserted
|
|
|
|
|
`secrets.kv.destroy` *means* `destroy` would be authoring policy semantics it
|
|
|
|
|
does not own, over vocabularies it does not own. The failure mode is also
|
|
|
|
|
asymmetric: a wrong mapping silently accepts a claim approved for a
|
|
|
|
|
*different* action, which is worse than no mapping at all. A consumer that
|
|
|
|
|
finds itself wanting one should read that as a signal it is about to compare
|
|
|
|
|
the wrong two things.
|
|
|
|
|
|
|
|
|
|
**`binding.pdp_digest` is the correspondence.** It sidesteps vocabulary
|
|
|
|
|
entirely: it is the PDP's own `NewDecisionBinding.request_digest`, recorded at
|
|
|
|
|
issue time, so comparing
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
claim.binding.pdp_digest == decision.binding.request_digest
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
compares the PDP's digest to the PDP's digest, in one vocabulary, with no
|
|
|
|
|
translation by anyone. That is strictly stronger than a name-to-name mapping
|
|
|
|
|
could be.
|
|
|
|
|
|
|
|
|
|
`pdp_digest` is **always present** on the claim and is `null` when the approval
|
|
|
|
|
was not issued against a PDP decision — a stated fact rather than a missing
|
|
|
|
|
key, so a consumer cannot read absence as an oversight. It is not required on
|
|
|
|
|
every approval, because approvals legitimately exist that no decision preceded.
|
|
|
|
|
|
Implement GH-DEC-2026-008: declared PDP-path intent, enforced at issue
Gate House ruled binding.pdp_digest is the binding correspondence on the
GH-DEC-2026-003 path and is required there, having rejected a vocabulary
mapping for the reasons we gave. It asked this engine to record the PDP
digest at issue for approvals intended for that path, and to have the
claim state which approvals those are rather than leaving it to the
requester's memory.
Schema v3 adds approvals.pdp_path. create() refuses pdp_path true without
a pdp_digest, so an approval that would be unusable on the path fails at
issue rather than at the protected side effect. The claim exposes
binding.pdp_path, which makes it a guarantee rather than a hint: pdp_path
true implies pdp_digest is non-null.
Intent is declared and never inferred. A pdp_digest that happens to be
present is not a declaration anybody made, so a recorded digest alone
leaves pdp_path false, legacy rows migrate to false rather than being
back-filled from their digests, and a successor inherits its
predecessor's declaration. Approvals issued before the ruling stay usable
by consumers in this engine's own vocabulary and are simply not usable on
the PDP path -- the ruling's intended cost, stated as such.
Schema, both published examples, a v2-to-v3 migration test asserting
survivors keep their digest while declaring no path intent, and tests for
refusal at issue, claim exposure, non-inference, and successor
inheritance. 102 tests pass (8 new).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvyJPAaVCGsVheVhcCwNND
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 411227@bnt-lap001
Assistant-Session: d566f6d3-bcaf-43c3-bc5e-3ddd0f64b535
2026-09-06 14:51:23 +02:00
|
|
|
### Declared path intent — `binding.pdp_path`
|
|
|
|
|
|
|
|
|
|
`GH-DEC-2026-008` requires `pdp_digest` on the `GH-DEC-2026-003` path. This
|
|
|
|
|
engine enforces that **at issue, not at consume**: an approval declared with
|
|
|
|
|
`pdp_path: true` and no `pdp_digest` is refused at create. Discovering an
|
|
|
|
|
unusable approval at the moment of the protected side effect is the worst place
|
|
|
|
|
to find out.
|
|
|
|
|
|
|
|
|
|
So `binding.pdp_path` is a guarantee, not a hint: **`pdp_path: true` implies
|
|
|
|
|
`pdp_digest` is non-null.** A consumer on that path MUST require `pdp_path`
|
|
|
|
|
true, and MUST NOT infer path intent from a `pdp_digest` that merely happens to
|
|
|
|
|
be present — a digest recorded for another reason is not a declaration that
|
|
|
|
|
anybody made.
|
|
|
|
|
|
|
|
|
|
Intent is declared by the requester and never back-filled. Approvals issued
|
|
|
|
|
before schema v3 carry `pdp_path: false` regardless of any digest they hold,
|
|
|
|
|
and a successor created by supersession inherits its predecessor's declaration.
|
|
|
|
|
|
State pdp_digest explicitly; decline to publish a vocabulary mapping
flex-auth asked whether this engine should publish an action/target
mapping between the claim binding's vocabulary (secrets.kv.destroy,
{"id": "lane-openbao-root"}) and a policy package's (destroy, lane:...),
since their package makes no cross-check that a claim was approved for
the action being decided.
Answered no. A PIP asserting that one vocabulary's action means
another's would author policy semantics it does not own, over
vocabularies it does not own, and the failure mode is asymmetric: a wrong
mapping silently accepts a claim approved for a different action, which
is worse than no mapping. binding.pdp_digest is the correspondence and
sidesteps vocabulary entirely -- it compares the PDP's own digest to the
PDP's own digest, with no translation by anyone.
Implemented the part that was ours. pdp_digest was emitted only when
recorded, so a consumer could not distinguish "not issued against a
decision" from "we forgot to look". It is now always present and null in
that case, required-but-nullable in the schema, and documented as
something a PEP on a privileged lane must refuse. This engine states the
fact; enforcing the lane's policy stays with the consumer.
Both published examples were already contradicting the updated schema by
omitting the field -- the same fixture-versus-contract defect flex-auth
hit twice this week and that secrets-engine implemented. Fixed both, made
them cover the PDP-bound and unbound shapes so neither is inferred from
the other, and added tests/test_examples.py to validate every example
against the schema so the class cannot recur here. jsonschema added as a
dev dependency.
94 tests pass (6 new).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvyJPAaVCGsVheVhcCwNND
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 411227@bnt-lap001
Assistant-Session: d566f6d3-bcaf-43c3-bc5e-3ddd0f64b535
2026-09-06 09:32:11 +02:00
|
|
|
**A PEP on a privileged lane MUST refuse a claim whose `pdp_digest` is
|
|
|
|
|
`null`.** Such a claim proves an approval exists; it does not prove the
|
|
|
|
|
approval was issued against the request now being decided, and no vocabulary
|
|
|
|
|
comparison recovers that. Requiring it is the consumer's own gate — this engine
|
|
|
|
|
states the fact and does not enforce the lane's policy.
|
|
|
|
|
|
Implement the engine spine: claim, outbox, machine, API
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
2026-08-29 12:52:49 +02:00
|
|
|
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:
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
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:
|
|
|
|
|
|
|
|
|
|
1. The claim resolved from this engine; an outage fails the action closed.
|
|
|
|
|
2. `issuer` is `approval-engine`.
|
|
|
|
|
3. `valid_now` is true and `consumed` is false.
|
|
|
|
|
4. `binding.digest` equals the digest of the binding the consumer computed
|
|
|
|
|
from the proposed action, **or** `binding.pdp_digest` equals the
|
State pdp_digest explicitly; decline to publish a vocabulary mapping
flex-auth asked whether this engine should publish an action/target
mapping between the claim binding's vocabulary (secrets.kv.destroy,
{"id": "lane-openbao-root"}) and a policy package's (destroy, lane:...),
since their package makes no cross-check that a claim was approved for
the action being decided.
Answered no. A PIP asserting that one vocabulary's action means
another's would author policy semantics it does not own, over
vocabularies it does not own, and the failure mode is asymmetric: a wrong
mapping silently accepts a claim approved for a different action, which
is worse than no mapping. binding.pdp_digest is the correspondence and
sidesteps vocabulary entirely -- it compares the PDP's own digest to the
PDP's own digest, with no translation by anyone.
Implemented the part that was ours. pdp_digest was emitted only when
recorded, so a consumer could not distinguish "not issued against a
decision" from "we forgot to look". It is now always present and null in
that case, required-but-nullable in the schema, and documented as
something a PEP on a privileged lane must refuse. This engine states the
fact; enforcing the lane's policy stays with the consumer.
Both published examples were already contradicting the updated schema by
omitting the field -- the same fixture-versus-contract defect flex-auth
hit twice this week and that secrets-engine implemented. Fixed both, made
them cover the PDP-bound and unbound shapes so neither is inferred from
the other, and added tests/test_examples.py to validate every example
against the schema so the class cannot recur here. jsonschema added as a
dev dependency.
94 tests pass (6 new).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvyJPAaVCGsVheVhcCwNND
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 411227@bnt-lap001
Assistant-Session: d566f6d3-bcaf-43c3-bc5e-3ddd0f64b535
2026-09-06 09:32:11 +02:00
|
|
|
`NewDecisionBinding` digest of that request. On a privileged lane, take the
|
|
|
|
|
second: require `binding.pdp_digest` to be non-null and equal, and refuse
|
|
|
|
|
the claim otherwise. See "Action and target vocabulary" above.
|
Implement the engine spine: claim, outbox, machine, API
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
2026-08-29 12:52:49 +02:00
|
|
|
5. `freshness.not_after` is still in the future.
|
|
|
|
|
6. `reason_code` is `ok`.
|
|
|
|
|
|
|
|
|
|
Local fixtures, workplan ids, and prose are not this claim.
|
|
|
|
|
|
|
|
|
|
## Examples
|
|
|
|
|
|
|
|
|
|
See [`../examples/claim.valid.json`](../examples/claim.valid.json) and
|
|
|
|
|
[`../examples/claim.revoked.json`](../examples/claim.revoked.json).
|