Finish approval engine spine
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a05e2e-805b-7042-a750-71f473bceea2
This commit is contained in:
parent
e7c210bf56
commit
c3f1dfbc07
18 changed files with 526 additions and 75 deletions
|
|
@ -185,8 +185,10 @@ frontmatter and `layer.yaml`.
|
||||||
|
|
||||||
- Answer "is this approval valid for this exact binding, and has it been used?"
|
- Answer "is this approval valid for this exact binding, and has it been used?"
|
||||||
Never "may this actor do X".
|
Never "may this actor do X".
|
||||||
- Do not implement consumption until `GH-WP-0002-T06` / `APPROVAL-WP-0001-T05`
|
- Consumption follows `GH-DEC-2026-003` and
|
||||||
settles the contract with `access-engine`.
|
`gate-house/docs/contracts/approval-consumption.md`: the PEP consumes by CAS
|
||||||
|
before the protected side effect; same-digest retries are idempotent,
|
||||||
|
different digests conflict, and there is no unconsume.
|
||||||
- The outbox is local. Do not emit synchronously to `audit-core` inside a
|
- The outbox is local. Do not emit synchronously to `audit-core` inside a
|
||||||
mutation transaction.
|
mutation transaction.
|
||||||
- Approval evidence is load-bearing. Atomicity covers crash, not compromise.
|
- Approval evidence is load-bearing. Atomicity covers crash, not compromise.
|
||||||
|
|
|
||||||
29
INTENT.md
29
INTENT.md
|
|
@ -215,23 +215,21 @@ proposed, not assigned), this engine publishes the contract at its own
|
||||||
boundary and yields to the schema when it exists. Inventing a permanent local
|
boundary and yields to the schema when it exists. Inventing a permanent local
|
||||||
shape is the drift §17 exists to prevent.
|
shape is the drift §17 exists to prevent.
|
||||||
|
|
||||||
## Consumption Ordering — Unresolved
|
## Consumption Ordering — Resolved by GH-DEC-2026-003
|
||||||
|
|
||||||
The decision precedes the action, and the action precedes consumption. Three
|
The decision precedes consumption, and consumption precedes the protected
|
||||||
failure modes are named, and each needs an owner (standard §9.7.4, §16):
|
side effect. Gate House settled this in `GH-DEC-2026-003` and
|
||||||
|
`docs/contracts/approval-consumption.md`. Three failure modes are named:
|
||||||
|
|
||||||
- an allow rendered against an approval that is then never consumed;
|
- an allow rendered against an approval that is then never consumed;
|
||||||
- an approval consumed twice by racing callers;
|
- an approval consumed twice by racing callers;
|
||||||
- an approval consumed after the action it authorized has already failed.
|
- an approval consumed after the action it authorized has already failed.
|
||||||
|
|
||||||
`approval-engine` performs the mutation, because `access-engine` never mutates.
|
The PEP signals consumption by presenting the decision binding's canonical
|
||||||
But *who signals consumption, at what point relative to the decision, and what
|
request digest to this engine. approval-engine performs the CAS before the PEP
|
||||||
happens on each of the three states above* is a contract between the two engines
|
acts; `access-engine` never mutates. Same-digest retries are idempotent success,
|
||||||
and is **not yet settled**. It is recorded in the standard's §16, in
|
different digests conflict, and a failure after consume leaves the approval
|
||||||
`GH-WP-0002-T06`, and is required before `FLEX-WP-0017` T05. Raised by
|
spent. There is no unconsume.
|
||||||
`flex-auth`.
|
|
||||||
|
|
||||||
Nothing here may be implemented by guessing that contract.
|
|
||||||
|
|
||||||
## What approval-engine Does Not Own
|
## What approval-engine Does Not Own
|
||||||
|
|
||||||
|
|
@ -306,9 +304,9 @@ word under standard §8, and this engine must never claim it.
|
||||||
- **It claims the outbox closes omission.** Atomicity covers crash. Treating it
|
- **It claims the outbox closes omission.** Atomicity covers crash. Treating it
|
||||||
as covering a compromised source, or skipping the heartbeat, is the v0.6
|
as covering a compromised source, or skipping the heartbeat, is the v0.6
|
||||||
overclaim this engine must not reintroduce.
|
overclaim this engine must not reintroduce.
|
||||||
- **It implements consumption by guessing.** The three races are named and
|
- **It diverges from the consumption contract.** PDP mutation,
|
||||||
unowned. Code that picks a side is a contract with `access-engine` that
|
action-before-consume, different-digest reuse, or unconsume violates
|
||||||
`access-engine` has not assented to.
|
`GH-DEC-2026-003`.
|
||||||
- **It cites observation or containment that has not happened.** Nothing is
|
- **It cites observation or containment that has not happened.** Nothing is
|
||||||
observed in production, and nothing can be contained automatically
|
observed in production, and nothing can be contained automatically
|
||||||
(companion §10).
|
(companion §10).
|
||||||
|
|
@ -329,5 +327,6 @@ word under standard §8, and this engine must never claim it.
|
||||||
rendered against approval A for request R cannot be replayed for request R';
|
rendered against approval A for request R cannot be replayed for request R';
|
||||||
7. load-bearing classes declare a heartbeat or reconciliation, and divergence
|
7. load-bearing classes declare a heartbeat or reconciliation, and divergence
|
||||||
from `audit-core`'s event count is a finding;
|
from `audit-core`'s event count is a finding;
|
||||||
8. consumption is not implemented until `GH-WP-0002-T06` settles the contract;
|
8. consumption implements `GH-DEC-2026-003`, including atomic use evidence,
|
||||||
|
same-digest idempotency, different-digest conflict, and no unconsume;
|
||||||
9. `FLEX-WP-0017` T03 and T05 are unblocked.
|
9. `FLEX-WP-0017` T03 and T05 are unblocked.
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ Flexibility here would be a defect. Graded, evidence-based progression belongs t
|
||||||
|
|
||||||
See [INTENT.md](INTENT.md) and [SCOPE.md](SCOPE.md). Declaration: [layer.yaml](layer.yaml).
|
See [INTENT.md](INTENT.md) and [SCOPE.md](SCOPE.md). Declaration: [layer.yaml](layer.yaml).
|
||||||
Claim: [docs/approval-claim.md](docs/approval-claim.md).
|
Claim: [docs/approval-claim.md](docs/approval-claim.md).
|
||||||
|
Consume: [docs/approval-consumption.md](docs/approval-consumption.md).
|
||||||
Origin: `flex-auth` `FLEX-DEC-2026-001`, raised while assenting to the security
|
Origin: `flex-auth` `FLEX-DEC-2026-001`, raised while assenting to the security
|
||||||
layer model.
|
layer model.
|
||||||
|
|
||||||
|
|
@ -29,4 +30,6 @@ make test
|
||||||
python3 -m approval_engine.cli serve --db approvals.sqlite
|
python3 -m approval_engine.cli serve --db approvals.sqlite
|
||||||
```
|
```
|
||||||
|
|
||||||
There is no public `consume`. That waits on `GH-WP-0002-T06`.
|
`POST /v1/approvals/{id}/consume` implements `GH-DEC-2026-003`: the PEP
|
||||||
|
atomically spends the approval before the protected side effect. Same-digest
|
||||||
|
retries are idempotent; a different digest conflicts.
|
||||||
|
|
|
||||||
23
SCOPE.md
23
SCOPE.md
|
|
@ -20,8 +20,8 @@ whether the action is permitted, does not author the policy that requires an
|
||||||
approval, and does not archive the trail.
|
approval, and does not archive the trail.
|
||||||
|
|
||||||
The first cut is the spine that makes Canon `T-06 — Approval Replay` passable
|
The first cut is the spine that makes Canon `T-06 — Approval Replay` passable
|
||||||
and unblocks `FLEX-WP-0017` T03/T05 — and nothing that has to guess a contract
|
and supplies the approval object and consumption mutation needed by protected
|
||||||
the other side has not assented to.
|
systems. Consumption follows the assented `GH-DEC-2026-003` contract.
|
||||||
|
|
||||||
## In Scope
|
## In Scope
|
||||||
|
|
||||||
|
|
@ -34,6 +34,8 @@ the other side has not assented to.
|
||||||
`superseded / revoked / expired` as terminal exits from `valid`.
|
`superseded / revoked / expired` as terminal exits from `valid`.
|
||||||
- Atomic supersession and single consumption (compare-and-swap, never
|
- Atomic supersession and single consumption (compare-and-swap, never
|
||||||
read-then-write).
|
read-then-write).
|
||||||
|
- A PEP-called consume mutation before the protected side effect, with
|
||||||
|
same-request idempotency, different-request conflict, and no unconsume.
|
||||||
- Revocation that does not require the holder's cooperation and is effective
|
- Revocation that does not require the holder's cooperation and is effective
|
||||||
at the next use.
|
at the next use.
|
||||||
- A local transactional outbox in this engine's own store; no synchronous
|
- A local transactional outbox in this engine's own store; no synchronous
|
||||||
|
|
@ -64,9 +66,9 @@ the other side has not assented to.
|
||||||
- A general state-machine service for other concepts.
|
- A general state-machine service for other concepts.
|
||||||
- PEP shape, unreachable-engine stance maps, Railiance `rail-*` / `rapp-*` /
|
- PEP shape, unreachable-engine stance maps, Railiance `rail-*` / `rapp-*` /
|
||||||
`reef-*` axes. An approval is not a workload (statute §20.1).
|
`reef-*` axes. An approval is not a workload (statute §20.1).
|
||||||
- Consumption signaling relative to the decision, until `GH-WP-0002-T06`
|
- Any consumption protocol other than `GH-DEC-2026-003`, including PDP
|
||||||
settles it with `access-engine`. Guessing that contract is out of scope
|
mutation, action-before-consume, unconsume, reserve/release, or inferring use
|
||||||
even as a prototype.
|
from a decision record.
|
||||||
|
|
||||||
## Relevant When
|
## Relevant When
|
||||||
|
|
||||||
|
|
@ -89,7 +91,8 @@ the other side has not assented to.
|
||||||
- The need is a workflow inbox, a meeting, or a notification surface.
|
- The need is a workflow inbox, a meeting, or a notification surface.
|
||||||
- The need is graded readiness or a maturity ladder.
|
- The need is graded readiness or a maturity ladder.
|
||||||
- The work is mapping Railiance operational axes onto security objects.
|
- The work is mapping Railiance operational axes onto security objects.
|
||||||
- Consumption ordering is being "solved" from this side alone.
|
- A caller wants to consume without presenting the PDP decision binding's
|
||||||
|
canonical request digest.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
|
|
@ -97,8 +100,8 @@ the other side has not assented to.
|
||||||
outbox, WSGI introspection API, claim contract. Not a production deploy.
|
outbox, WSGI introspection API, claim contract. Not a production deploy.
|
||||||
- Layer declaration: INTENT frontmatter + `layer.yaml`. Cadence declared in
|
- Layer declaration: INTENT frontmatter + `layer.yaml`. Cadence declared in
|
||||||
`cadence.yaml`. No Tooling contacts.
|
`cadence.yaml`. No Tooling contacts.
|
||||||
- Consumption is not a public API (`APPROVAL-WP-0001-T05` waits on
|
- Consumption is public at `POST /v1/approvals/{id}/consume` under
|
||||||
`GH-WP-0002-T06`).
|
`GH-DEC-2026-003`; the endpoint is a lifecycle mutation, never a decision.
|
||||||
- Taxonomy request-claim schema is still unassigned; the local claim yields.
|
- Taxonomy request-claim schema is still unassigned; the local claim yields.
|
||||||
- Work: `APPROVAL-WP-0001`. Tests: `make test`.
|
- Work: `APPROVAL-WP-0001`. Tests: `make test`.
|
||||||
|
|
||||||
|
|
@ -158,8 +161,8 @@ type: api
|
||||||
title: Approval object lifecycle
|
title: Approval object lifecycle
|
||||||
description: >
|
description: >
|
||||||
Create, collect authenticated entries with distinct-approver counting,
|
Create, collect authenticated entries with distinct-approver counting,
|
||||||
atomically supersede, and revoke without holder cooperation. No public
|
atomically supersede, consume with request-digest idempotency, and revoke
|
||||||
consume until consumption ordering is settled.
|
without holder cooperation.
|
||||||
keywords: [approval, state-machine, cas, revocation, supersession]
|
keywords: [approval, state-machine, cas, revocation, supersession]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,20 @@
|
||||||
|
|
||||||
| Kind | ID | Status | Lane | Source |
|
| Kind | ID | Status | Lane | Source |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| workplan | APPROVAL-WP-0001 | active | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
| workplan | APPROVAL-WP-0001 | finished | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
||||||
|
| workplan | APPROVAL-WP-0002 | proposed | — | workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md |
|
||||||
| task | APPROVAL-WP-0001-T01 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
| task | APPROVAL-WP-0001-T01 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
||||||
| task | APPROVAL-WP-0001-T02 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
| task | APPROVAL-WP-0001-T02 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
||||||
| task | APPROVAL-WP-0001-T03 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
| task | APPROVAL-WP-0001-T03 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
||||||
| task | APPROVAL-WP-0001-T04 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
| task | APPROVAL-WP-0001-T04 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
||||||
| task | APPROVAL-WP-0001-T05 | wait | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
| task | APPROVAL-WP-0001-T05 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
||||||
| task | APPROVAL-WP-0001-T06 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
| task | APPROVAL-WP-0001-T06 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
||||||
| task | APPROVAL-WP-0001-T07 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
| task | APPROVAL-WP-0001-T07 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
||||||
| task | APPROVAL-WP-0001-T08 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
| task | APPROVAL-WP-0001-T08 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
||||||
| task | APPROVAL-WP-0001-T09 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
| task | APPROVAL-WP-0001-T09 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
|
||||||
|
| task | APPROVAL-WP-0002-T01 | todo | — | workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md |
|
||||||
|
| task | APPROVAL-WP-0002-T02 | todo | — | workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md |
|
||||||
|
| task | APPROVAL-WP-0002-T03 | todo | — | workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md |
|
||||||
|
| task | APPROVAL-WP-0002-T04 | todo | — | workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md |
|
||||||
|
| task | APPROVAL-WP-0002-T05 | todo | — | workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md |
|
||||||
|
| intake | APPROVAL-IN-0001 | open | blue | intakes/intakes.md |
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""HTTP surface. Introspection and mutation; never a decision; never consume."""
|
"""HTTP surface. Introspection and lifecycle mutation; never a decision."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -87,8 +87,6 @@ class App:
|
||||||
if len(parts) >= 3 and parts[0] == "v1" and parts[1] == "approvals":
|
if len(parts) >= 3 and parts[0] == "v1" and parts[1] == "approvals":
|
||||||
approval_id = parts[2]
|
approval_id = parts[2]
|
||||||
rest = parts[3:]
|
rest = parts[3:]
|
||||||
if rest == ["consume"] or path.endswith("/consume"):
|
|
||||||
return 404, {"error": "not_found", "message": "consume is not implemented"}
|
|
||||||
if not rest and method == "GET":
|
if not rest and method == "GET":
|
||||||
return 200, self.engine.get(approval_id).as_dict()
|
return 200, self.engine.get(approval_id).as_dict()
|
||||||
if rest == ["claim"] and method == "GET":
|
if rest == ["claim"] and method == "GET":
|
||||||
|
|
@ -107,7 +105,14 @@ class App:
|
||||||
if rest == ["supersede"] and method == "POST":
|
if rest == ["supersede"] and method == "POST":
|
||||||
data = _read_json(environ)
|
data = _read_json(environ)
|
||||||
return 200, self.engine.supersede(approval_id, data.get("successor_id"))
|
return 200, self.engine.supersede(approval_id, data.get("successor_id"))
|
||||||
if "check" in path or path.endswith("/authorize") or path.endswith("/consume"):
|
if rest == ["consume"] and method == "POST":
|
||||||
|
data = _read_json(environ)
|
||||||
|
return 200, self.engine.consume(
|
||||||
|
approval_id,
|
||||||
|
data.get("request_digest"),
|
||||||
|
decision_id=data.get("decision_id"),
|
||||||
|
)
|
||||||
|
if "check" in path or path.endswith("/authorize"):
|
||||||
return 404, {"error": "not_found", "message": "no such surface"}
|
return 404, {"error": "not_found", "message": "no such surface"}
|
||||||
return 404, {"error": "not_found", "message": path}
|
return 404, {"error": "not_found", "message": path}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""SQLite-backed approval object, closed machine, local outbox.
|
"""SQLite-backed approval object, closed machine, local outbox.
|
||||||
|
|
||||||
All mutations run in BEGIN IMMEDIATE and insert the outbox row before COMMIT.
|
All mutations run in BEGIN IMMEDIATE and insert the outbox row before COMMIT.
|
||||||
There is no public consume; _cas_consume exists only so use-class emission
|
Consumption follows GH-DEC-2026-003: the PEP presents the decision binding's
|
||||||
can be tested without guessing GH-WP-0002-T06.
|
request digest before the protected side effect.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -50,6 +50,9 @@ CREATE TABLE IF NOT EXISTS approvals (
|
||||||
expires_at TEXT NOT NULL,
|
expires_at TEXT NOT NULL,
|
||||||
required_count INTEGER NOT NULL,
|
required_count INTEGER NOT NULL,
|
||||||
superseded_by TEXT,
|
superseded_by TEXT,
|
||||||
|
consumed_digest TEXT,
|
||||||
|
consumed_decision_id TEXT,
|
||||||
|
consumed_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
@ -109,12 +112,15 @@ class Approval:
|
||||||
expires_at: str
|
expires_at: str
|
||||||
required_count: int
|
required_count: int
|
||||||
superseded_by: str | None
|
superseded_by: str | None
|
||||||
|
consumed_digest: str | None
|
||||||
|
consumed_decision_id: str | None
|
||||||
|
consumed_at: str | None
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
entries: list[dict[str, Any]]
|
entries: list[dict[str, Any]]
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {
|
result = {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"binding": {
|
"binding": {
|
||||||
|
|
@ -129,6 +135,17 @@ class Approval:
|
||||||
"created_at": self.created_at,
|
"created_at": self.created_at,
|
||||||
"updated_at": self.updated_at,
|
"updated_at": self.updated_at,
|
||||||
}
|
}
|
||||||
|
if self.consumed_digest:
|
||||||
|
result["consumption"] = {
|
||||||
|
"request_digest": self.consumed_digest,
|
||||||
|
**(
|
||||||
|
{"decision_id": self.consumed_decision_id}
|
||||||
|
if self.consumed_decision_id
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
"consumed_at": self.consumed_at,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class Engine:
|
class Engine:
|
||||||
|
|
@ -172,6 +189,12 @@ class Engine:
|
||||||
conn = self._conn()
|
conn = self._conn()
|
||||||
try:
|
try:
|
||||||
conn.executescript(SCHEMA)
|
conn.executescript(SCHEMA)
|
||||||
|
columns = {
|
||||||
|
row["name"] for row in conn.execute("PRAGMA table_info(approvals)").fetchall()
|
||||||
|
}
|
||||||
|
for name in ("consumed_digest", "consumed_decision_id", "consumed_at"):
|
||||||
|
if name not in columns:
|
||||||
|
conn.execute(f"ALTER TABLE approvals ADD COLUMN {name} TEXT")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except sqlite3.Error as exc:
|
except sqlite3.Error as exc:
|
||||||
raise StoreUnavailable(str(exc)) from exc
|
raise StoreUnavailable(str(exc)) from exc
|
||||||
|
|
@ -295,6 +318,9 @@ class Engine:
|
||||||
expires_at=row["expires_at"],
|
expires_at=row["expires_at"],
|
||||||
required_count=row["required_count"],
|
required_count=row["required_count"],
|
||||||
superseded_by=row["superseded_by"],
|
superseded_by=row["superseded_by"],
|
||||||
|
consumed_digest=row["consumed_digest"],
|
||||||
|
consumed_decision_id=row["consumed_decision_id"],
|
||||||
|
consumed_at=row["consumed_at"],
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
updated_at=row["updated_at"],
|
updated_at=row["updated_at"],
|
||||||
entries=entries,
|
entries=entries,
|
||||||
|
|
@ -487,16 +513,51 @@ class Engine:
|
||||||
raise
|
raise
|
||||||
return self.get(approval_id)
|
return self.get(approval_id)
|
||||||
|
|
||||||
def _cas_consume(self, approval_id: str) -> Approval:
|
def consume(
|
||||||
"""Unexported seam. Do not wire to HTTP. Blocked on GH-WP-0002-T06."""
|
self,
|
||||||
|
approval_id: str,
|
||||||
|
request_digest: str | None,
|
||||||
|
*,
|
||||||
|
decision_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Atomically spend an approval for one decision-bound request.
|
||||||
|
|
||||||
|
Repeating the same request digest is an idempotent success. A different
|
||||||
|
digest against a consumed object conflicts and the caller must not act.
|
||||||
|
"""
|
||||||
|
if request_digest is None:
|
||||||
|
raise Unprocessable("request_digest is required")
|
||||||
|
if not isinstance(request_digest, str):
|
||||||
|
raise Unprocessable("request_digest must be a string")
|
||||||
|
digest = require_digest(request_digest)
|
||||||
|
if decision_id is not None and (
|
||||||
|
not isinstance(decision_id, str) or not decision_id
|
||||||
|
):
|
||||||
|
raise Unprocessable("decision_id must be a non-empty string")
|
||||||
conn = self._conn()
|
conn = self._conn()
|
||||||
now = iso(self.now())
|
now = iso(self.now())
|
||||||
|
idempotent = False
|
||||||
try:
|
try:
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
row = conn.execute("SELECT * FROM approvals WHERE id=?", (approval_id,)).fetchone()
|
row = conn.execute("SELECT * FROM approvals WHERE id=?", (approval_id,)).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise NotFound(approval_id)
|
raise NotFound(approval_id)
|
||||||
|
if row["status"] == "consumed":
|
||||||
|
if row["consumed_digest"] != digest:
|
||||||
|
conn.rollback()
|
||||||
|
raise Conflict("approval already consumed for a different request digest")
|
||||||
|
idempotent = True
|
||||||
|
stored_decision_id = row["consumed_decision_id"]
|
||||||
|
conn.commit()
|
||||||
|
return {
|
||||||
|
"approval_id": approval_id,
|
||||||
|
"status": "consumed",
|
||||||
|
"request_digest": digest,
|
||||||
|
**({"decision_id": stored_decision_id} if stored_decision_id else {}),
|
||||||
|
"consumed_at": row["consumed_at"],
|
||||||
|
"idempotent": idempotent,
|
||||||
|
}
|
||||||
if row["status"] != "approved":
|
if row["status"] != "approved":
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise Conflict(f"cannot consume from status {row['status']}")
|
raise Conflict(f"cannot consume from status {row['status']}")
|
||||||
|
|
@ -504,9 +565,10 @@ class Engine:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise Conflict("cannot consume outside validity window")
|
raise Conflict("cannot consume outside validity window")
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"UPDATE approvals SET status='consumed', updated_at=? "
|
"UPDATE approvals SET status='consumed', consumed_digest=?, "
|
||||||
|
"consumed_decision_id=?, consumed_at=?, updated_at=? "
|
||||||
"WHERE id=? AND status='approved'",
|
"WHERE id=? AND status='approved'",
|
||||||
(now, approval_id),
|
(digest, decision_id, now, now, approval_id),
|
||||||
)
|
)
|
||||||
if cur.rowcount != 1:
|
if cur.rowcount != 1:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
|
|
@ -516,7 +578,11 @@ class Engine:
|
||||||
"use",
|
"use",
|
||||||
approval_id,
|
approval_id,
|
||||||
actor=row["actor"],
|
actor=row["actor"],
|
||||||
extra={"binding_digest": row["binding_digest"]},
|
extra={
|
||||||
|
"binding_digest": row["binding_digest"],
|
||||||
|
"request_digest": digest,
|
||||||
|
**({"decision_id": decision_id} if decision_id else {}),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except sqlite3.Error as exc:
|
except sqlite3.Error as exc:
|
||||||
|
|
@ -525,7 +591,14 @@ class Engine:
|
||||||
except Exception:
|
except Exception:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise
|
raise
|
||||||
return self.get(approval_id)
|
return {
|
||||||
|
"approval_id": approval_id,
|
||||||
|
"status": "consumed",
|
||||||
|
"request_digest": digest,
|
||||||
|
**({"decision_id": decision_id} if decision_id else {}),
|
||||||
|
"consumed_at": now,
|
||||||
|
"idempotent": idempotent,
|
||||||
|
}
|
||||||
|
|
||||||
def _outbox_insert(
|
def _outbox_insert(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ classes:
|
||||||
action: approval.issuance
|
action: approval.issuance
|
||||||
use:
|
use:
|
||||||
action: approval.use
|
action: approval.use
|
||||||
note: "Internal CAS only until GH-WP-0002-T06."
|
note: "Public CAS consumption under GH-DEC-2026-003."
|
||||||
supersession:
|
supersession:
|
||||||
action: approval.supersession
|
action: approval.supersession
|
||||||
revocation:
|
revocation:
|
||||||
|
|
|
||||||
43
docs/approval-consumption.md
Normal file
43
docs/approval-consumption.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Approval consumption API
|
||||||
|
|
||||||
|
Status: implemented under Gate House decision `GH-DEC-2026-003`.
|
||||||
|
|
||||||
|
The normative protocol is
|
||||||
|
`gate-house/docs/contracts/approval-consumption.md`. This document records the
|
||||||
|
approval-engine implementation surface; it does not redefine the protocol.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /v1/approvals/{id}/consume
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_digest": "sha256:<64 lowercase hex>",
|
||||||
|
"decision_id": "decision:optional-provenance"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The caller is the PEP that is about to perform the protected side effect. It
|
||||||
|
calls consume after an ALLOW and before that side effect. `request_digest` is
|
||||||
|
the canonical digest from the PDP decision binding, not a newly serialized
|
||||||
|
request and not approval-engine's native binding digest.
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
- First valid consume: atomically stores the digest, changes `approved` to
|
||||||
|
`consumed`, and inserts one `approval.use` outbox row in the same transaction.
|
||||||
|
- Same digest after consumption: `200` idempotent success and no second outbox
|
||||||
|
row.
|
||||||
|
- Different digest after consumption: `409 conflict`; the PEP must not act.
|
||||||
|
- Revoked, superseded, expired, outside-window, requested, or unknown object:
|
||||||
|
conflict or not-found; the PEP must not act.
|
||||||
|
- Outbox insert/store failure: `503`; the transaction rolls back and the PEP
|
||||||
|
must not act.
|
||||||
|
|
||||||
|
There is no unconsume, release, or reserve. If the protected side effect fails
|
||||||
|
after consumption, the approval remains spent and a retry needs a new approval.
|
||||||
|
|
||||||
|
The response is mutation evidence, not a permission decision. It contains no
|
||||||
|
`effect`, `allow`, `deny`, or decision result.
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
- Claim contract: [`approval-claim.md`](approval-claim.md)
|
- Claim contract: [`approval-claim.md`](approval-claim.md)
|
||||||
- Introspection: `GET /v1/approvals/{id}/claim`
|
- Introspection: `GET /v1/approvals/{id}/claim`
|
||||||
- Object: `POST /v1/approvals`, entries, revoke, supersede
|
- Object: `POST /v1/approvals`, entries, revoke, supersede
|
||||||
|
- Consumption: `POST /v1/approvals/{id}/consume`, called by the PEP before the
|
||||||
|
protected side effect under `GH-DEC-2026-003`
|
||||||
- Native `binding.digest` plus optional `binding.pdp_digest` for
|
- Native `binding.digest` plus optional `binding.pdp_digest` for
|
||||||
`NewDecisionBinding.request_digest`
|
`NewDecisionBinding.request_digest`
|
||||||
|
|
||||||
|
|
@ -15,12 +17,15 @@ T03 is unblocked on the **object**, not on a hub substitute. Validate the
|
||||||
claim before privileged production actions. Fail closed if this engine is
|
claim before privileged production actions. Fail closed if this engine is
|
||||||
unreachable.
|
unreachable.
|
||||||
|
|
||||||
## What does not exist yet
|
## Consumption contract
|
||||||
|
|
||||||
Consumption ordering (`GH-WP-0002-T06` / `APPROVAL-WP-0001-T05`). There is
|
Gate House settled ordering in `GH-DEC-2026-003` and
|
||||||
no public `consume`. `FLEX-WP-0017` T05 stays blocked **only** on that
|
`docs/contracts/approval-consumption.md`. The PEP presents the decision
|
||||||
contract, not on a missing object or a missing digest.
|
binding's `request_digest` and consumes by CAS before the protected side
|
||||||
|
effect. A same-digest retry is idempotent success; a different digest conflicts;
|
||||||
|
there is no unconsume. `access-engine` remains read-only and never calls this
|
||||||
|
mutation.
|
||||||
|
|
||||||
Canon T-06 against this implementation: `tests/test_t06_replay.py` (wrong
|
Canon T-06 against this implementation: `tests/test_t06_replay.py` (wrong
|
||||||
target, wrong action, later time, revoked, superseded). Consume-side replay
|
target, wrong action, later time, revoked, superseded, same-digest retry, and
|
||||||
is out of scope until T05.
|
different-digest consume replay).
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ object mutation.
|
||||||
| Class | When | `audit-core` `action` |
|
| Class | When | `audit-core` `action` |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `issuance` | object becomes `approved` (threshold met) | `approval.issuance` |
|
| `issuance` | object becomes `approved` (threshold met) | `approval.issuance` |
|
||||||
| `use` | object becomes `consumed` (internal CAS; not a public API until `GH-WP-0002-T06`) | `approval.use` |
|
| `use` | public CAS accepts first `request_digest` and object becomes `consumed` | `approval.use` |
|
||||||
| `supersession` | object becomes `superseded` | `approval.supersession` |
|
| `supersession` | object becomes `superseded` | `approval.supersession` |
|
||||||
| `revocation` | object becomes `revoked` | `approval.revocation` |
|
| `revocation` | object becomes `revoked` | `approval.revocation` |
|
||||||
| `heartbeat` | signed *nothing to report* plus counts | `approval.heartbeat` |
|
| `heartbeat` | signed *nothing to report* plus counts | `approval.heartbeat` |
|
||||||
|
|
|
||||||
26
intakes/intakes.md
Normal file
26
intakes/intakes.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Intake records
|
||||||
|
|
||||||
|
## APPROVAL-IN-0001 — Publish the shared Taxonomy request-claim schema
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: APPROVAL-IN-0001
|
||||||
|
kind: intake
|
||||||
|
title: Publish the shared Taxonomy request-claim schema
|
||||||
|
status: open
|
||||||
|
origin: residual
|
||||||
|
origin_ref: APPROVAL-WP-0001
|
||||||
|
priority: medium
|
||||||
|
owner: approval-engine
|
||||||
|
repo: approval-engine
|
||||||
|
lane: blue
|
||||||
|
tags:
|
||||||
|
- residual
|
||||||
|
created: '2026-09-01'
|
||||||
|
updated: '2026-09-01'
|
||||||
|
description: >-
|
||||||
|
NetKingdom statute §17 calls for a shared request-claim schema, but ownership
|
||||||
|
remains unassigned. approval-engine published a local approval claim contract
|
||||||
|
with explicit issuer, freshness, and binding digests and marked it as yielding
|
||||||
|
to the future Taxonomy artifact. Route this intake when the Taxonomy owner is
|
||||||
|
assigned; preserve mechanical replay and freshness semantics during adoption.
|
||||||
|
```
|
||||||
|
|
@ -27,16 +27,86 @@ def test_create_entry_claim_roundtrip(app):
|
||||||
assert "decision" not in claim
|
assert "decision" not in claim
|
||||||
|
|
||||||
|
|
||||||
def test_no_check_or_authorize_or_consume(app):
|
def test_no_check_or_authorize(app):
|
||||||
for path in (
|
for path in (
|
||||||
"/v1/check",
|
"/v1/check",
|
||||||
"/authorize",
|
"/authorize",
|
||||||
"/v1/approvals/00000000-0000-0000-0000-000000000001/consume",
|
|
||||||
"/v1/approvals/abc/consume",
|
|
||||||
):
|
):
|
||||||
status, body = call(app, "POST", path, {})
|
status, body = call(app, "POST", path, {})
|
||||||
assert status == 404
|
assert status == 404
|
||||||
assert "consume is not implemented" in body.get("message", "") or body["error"] == "not_found"
|
assert body["error"] == "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
def test_consume_endpoint_is_a_mutation_not_a_decision(app):
|
||||||
|
_, created = call(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/v1/approvals",
|
||||||
|
{"binding": binding(), "validity": validity()},
|
||||||
|
)
|
||||||
|
aid = created["id"]
|
||||||
|
call(app, "POST", f"/v1/approvals/{aid}/entries", {"subject_id": "user:alice"})
|
||||||
|
digest = "sha256:" + "12" * 32
|
||||||
|
status, result = call(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
f"/v1/approvals/{aid}/consume",
|
||||||
|
{"request_digest": digest, "decision_id": "decision:123"},
|
||||||
|
)
|
||||||
|
assert status == 200
|
||||||
|
assert result == {
|
||||||
|
"approval_id": aid,
|
||||||
|
"status": "consumed",
|
||||||
|
"request_digest": digest,
|
||||||
|
"decision_id": "decision:123",
|
||||||
|
"consumed_at": "2026-08-29T12:00:00+00:00",
|
||||||
|
"idempotent": False,
|
||||||
|
}
|
||||||
|
assert not ({"effect", "decision", "allow", "deny"} & set(result))
|
||||||
|
|
||||||
|
|
||||||
|
def test_consume_requires_request_digest(app):
|
||||||
|
_, created = call(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/v1/approvals",
|
||||||
|
{"binding": binding(), "validity": validity()},
|
||||||
|
)
|
||||||
|
aid = created["id"]
|
||||||
|
call(app, "POST", f"/v1/approvals/{aid}/entries", {"subject_id": "user:alice"})
|
||||||
|
status, body = call(app, "POST", f"/v1/approvals/{aid}/consume", {})
|
||||||
|
assert status == 422
|
||||||
|
assert body["error"] == "unprocessable"
|
||||||
|
|
||||||
|
status, body = call(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
f"/v1/approvals/{aid}/consume",
|
||||||
|
{"request_digest": ["not", "a", "digest"]},
|
||||||
|
)
|
||||||
|
assert status == 422
|
||||||
|
assert body["error"] == "unprocessable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_consume_endpoint_rejects_different_digest_replay(app):
|
||||||
|
_, created = call(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/v1/approvals",
|
||||||
|
{"binding": binding(), "validity": validity()},
|
||||||
|
)
|
||||||
|
aid = created["id"]
|
||||||
|
call(app, "POST", f"/v1/approvals/{aid}/entries", {"subject_id": "user:alice"})
|
||||||
|
first = "sha256:" + "45" * 32
|
||||||
|
second = "sha256:" + "67" * 32
|
||||||
|
assert call(
|
||||||
|
app, "POST", f"/v1/approvals/{aid}/consume", {"request_digest": first}
|
||||||
|
)[0] == 200
|
||||||
|
status, body = call(
|
||||||
|
app, "POST", f"/v1/approvals/{aid}/consume", {"request_digest": second}
|
||||||
|
)
|
||||||
|
assert status == 409
|
||||||
|
assert body["error"] == "conflict"
|
||||||
|
|
||||||
|
|
||||||
def test_claim_after_revoke(app):
|
def test_claim_after_revoke(app):
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from approval_engine.errors import Conflict
|
from approval_engine.errors import Conflict
|
||||||
from approval_engine.store import Engine
|
from approval_engine.store import Engine
|
||||||
from tests.conftest import approve
|
from tests.conftest import FROZEN, approve
|
||||||
|
|
||||||
|
|
||||||
def test_second_supersession_loses(engine):
|
def test_second_supersession_loses(engine):
|
||||||
|
|
@ -25,7 +26,7 @@ def test_second_supersession_loses(engine):
|
||||||
def test_concurrent_supersessions_one_winner():
|
def test_concurrent_supersessions_one_winner():
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = Path(tmp) / "a.sqlite"
|
path = Path(tmp) / "a.sqlite"
|
||||||
setup = Engine(path)
|
setup = Engine(path, clock=lambda: FROZEN)
|
||||||
obj = approve(setup)
|
obj = approve(setup)
|
||||||
setup.close()
|
setup.close()
|
||||||
|
|
||||||
|
|
@ -34,7 +35,7 @@ def test_concurrent_supersessions_one_winner():
|
||||||
barrier = threading.Barrier(2)
|
barrier = threading.Barrier(2)
|
||||||
|
|
||||||
def race():
|
def race():
|
||||||
eng = Engine(path)
|
eng = Engine(path, clock=lambda: FROZEN)
|
||||||
barrier.wait()
|
barrier.wait()
|
||||||
try:
|
try:
|
||||||
result = eng.supersede(obj.id)
|
result = eng.supersede(obj.id)
|
||||||
|
|
@ -51,20 +52,87 @@ def test_concurrent_supersessions_one_winner():
|
||||||
t.join()
|
t.join()
|
||||||
assert len(winners) == 1
|
assert len(winners) == 1
|
||||||
assert len(errors) == 1
|
assert len(errors) == 1
|
||||||
check = Engine(path)
|
check = Engine(path, clock=lambda: FROZEN)
|
||||||
assert check.get(obj.id).status == "superseded"
|
assert check.get(obj.id).status == "superseded"
|
||||||
check.close()
|
check.close()
|
||||||
|
|
||||||
|
|
||||||
def test_internal_consume_cas_once(engine):
|
def test_consume_same_digest_is_idempotent(engine):
|
||||||
obj = approve(engine)
|
obj = approve(engine)
|
||||||
engine._cas_consume(obj.id)
|
digest = "sha256:" + "ab" * 32
|
||||||
try:
|
first = engine.consume(obj.id, digest, decision_id="decision:first")
|
||||||
engine._cas_consume(obj.id)
|
second = engine.consume(obj.id, digest, decision_id="decision:retry")
|
||||||
raise AssertionError("double consume must conflict")
|
assert first["idempotent"] is False
|
||||||
except Conflict:
|
assert second["idempotent"] is True
|
||||||
pass
|
assert second["decision_id"] == "decision:first"
|
||||||
|
assert [item["class"] for item in engine.undrained()].count("use") == 1
|
||||||
claim = engine.claim(obj.id)
|
claim = engine.claim(obj.id)
|
||||||
assert claim["consumed"] is True
|
assert claim["consumed"] is True
|
||||||
assert claim["valid_now"] is False
|
assert claim["valid_now"] is False
|
||||||
assert claim["reason_code"] == "consumed"
|
assert claim["reason_code"] == "consumed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_consume_different_digest_conflicts(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
engine.consume(obj.id, "sha256:" + "ab" * 32)
|
||||||
|
try:
|
||||||
|
engine.consume(obj.id, "sha256:" + "cd" * 32)
|
||||||
|
raise AssertionError("different request digest must conflict")
|
||||||
|
except Conflict:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_same_digest_consume_is_one_use_event():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "consume.sqlite"
|
||||||
|
setup = Engine(path, clock=lambda: FROZEN)
|
||||||
|
obj = approve(setup)
|
||||||
|
setup.close()
|
||||||
|
digest = "sha256:" + "ef" * 32
|
||||||
|
results: list[bool] = []
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
|
||||||
|
def race():
|
||||||
|
eng = Engine(path, clock=lambda: FROZEN)
|
||||||
|
barrier.wait()
|
||||||
|
try:
|
||||||
|
results.append(eng.consume(obj.id, digest)["idempotent"])
|
||||||
|
finally:
|
||||||
|
eng.close()
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=race) for _ in range(2)]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join()
|
||||||
|
assert sorted(results) == [False, True]
|
||||||
|
check = Engine(path, clock=lambda: FROZEN)
|
||||||
|
assert [item["class"] for item in check.undrained()].count("use") == 1
|
||||||
|
check.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_database_migrates_consumption_columns():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "legacy.sqlite"
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE approvals (
|
||||||
|
id TEXT PRIMARY KEY, status TEXT NOT NULL,
|
||||||
|
binding_json TEXT NOT NULL, binding_digest TEXT NOT NULL,
|
||||||
|
pdp_digest TEXT, actor TEXT NOT NULL, principal TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL, purpose TEXT NOT NULL,
|
||||||
|
target_json TEXT NOT NULL, not_before TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL, required_count INTEGER NOT NULL,
|
||||||
|
superseded_by TEXT, created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
eng = Engine(path, clock=lambda: FROZEN)
|
||||||
|
columns = {
|
||||||
|
row["name"]
|
||||||
|
for row in eng._conn().execute("PRAGMA table_info(approvals)").fetchall()
|
||||||
|
}
|
||||||
|
assert {"consumed_digest", "consumed_decision_id", "consumed_at"} <= columns
|
||||||
|
eng.close()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from approval_engine.errors import StoreUnavailable
|
from approval_engine.errors import StoreUnavailable
|
||||||
from approval_engine.store import Engine
|
from approval_engine.store import Engine
|
||||||
from tests.conftest import approve, binding, validity
|
from tests.conftest import FROZEN, approve, binding, validity
|
||||||
|
|
||||||
|
|
||||||
def test_issuance_queued_in_same_commit(engine):
|
def test_issuance_queued_in_same_commit(engine):
|
||||||
|
|
@ -16,7 +16,7 @@ def test_issuance_queued_in_same_commit(engine):
|
||||||
|
|
||||||
|
|
||||||
def test_failed_outbox_rolls_back_mutation():
|
def test_failed_outbox_rolls_back_mutation():
|
||||||
eng = Engine(":memory:", fail_outbox=True)
|
eng = Engine(":memory:", clock=lambda: FROZEN, fail_outbox=True)
|
||||||
obj = eng.create(binding(), validity(), required_count=1)
|
obj = eng.create(binding(), validity(), required_count=1)
|
||||||
try:
|
try:
|
||||||
eng.add_entry(obj.id, "user:alice")
|
eng.add_entry(obj.id, "user:alice")
|
||||||
|
|
@ -70,3 +70,15 @@ def test_revocation_event_class(engine):
|
||||||
engine.revoke(obj.id)
|
engine.revoke(obj.id)
|
||||||
classes = [p["class"] for p in engine.undrained()]
|
classes = [p["class"] for p in engine.undrained()]
|
||||||
assert "revocation" in classes
|
assert "revocation" in classes
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_outbox_rolls_back_consume(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
engine.fail_outbox = True
|
||||||
|
try:
|
||||||
|
engine.consume(obj.id, "sha256:" + "34" * 32)
|
||||||
|
raise AssertionError("must fail")
|
||||||
|
except StoreUnavailable:
|
||||||
|
pass
|
||||||
|
assert engine.get(obj.id).status == "approved"
|
||||||
|
assert all(item["class"] != "use" for item in engine.undrained())
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@ Reuse a previously valid approval artifact for a different target, parameter
|
||||||
set, or later time. Pass: parameter binding, expiry, or replay protection
|
set, or later time. Pass: parameter binding, expiry, or replay protection
|
||||||
rejects the request.
|
rejects the request.
|
||||||
|
|
||||||
Consume-side replay (use twice) waits on GH-WP-0002-T06. This suite covers
|
Consume-side replay follows GH-DEC-2026-003: same-digest retries are
|
||||||
the object and claim side: wrong binding, expiry, revocation, supersession.
|
idempotent; a different digest against a consumed object conflicts.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from approval_engine.binding import binding_digest
|
from approval_engine.binding import binding_digest
|
||||||
|
from approval_engine.errors import Conflict
|
||||||
from approval_engine.store import Engine
|
from approval_engine.store import Engine
|
||||||
from tests.conftest import approve, binding, validity
|
from tests.conftest import approve, binding, validity
|
||||||
|
|
||||||
|
|
@ -65,3 +66,22 @@ def test_t06_superseded_rejected(engine):
|
||||||
obj = approve(engine)
|
obj = approve(engine)
|
||||||
engine.supersede(obj.id)
|
engine.supersede(obj.id)
|
||||||
assert _consumer_accepts(engine.claim(obj.id), binding()) is False
|
assert _consumer_accepts(engine.claim(obj.id), binding()) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_t06_consumed_approval_rejects_different_request_digest(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
first = "sha256:" + "56" * 32
|
||||||
|
engine.consume(obj.id, first)
|
||||||
|
try:
|
||||||
|
engine.consume(obj.id, "sha256:" + "78" * 32)
|
||||||
|
raise AssertionError("consume-side replay must conflict")
|
||||||
|
except Conflict:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_t06_consumed_approval_allows_same_request_retry(engine):
|
||||||
|
obj = approve(engine)
|
||||||
|
digest = "sha256:" + "9a" * 32
|
||||||
|
engine.consume(obj.id, digest)
|
||||||
|
retry = engine.consume(obj.id, digest)
|
||||||
|
assert retry["idempotent"] is True
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ type: workplan
|
||||||
title: "v0.7 alignment and the engine spine"
|
title: "v0.7 alignment and the engine spine"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: approval-engine
|
repo: approval-engine
|
||||||
status: active
|
status: finished
|
||||||
owner: grok
|
owner: grok
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
created: "2026-08-29"
|
created: "2026-08-29"
|
||||||
updated: "2026-08-29"
|
updated: "2026-09-01"
|
||||||
state_hub_workstream_id: "546f2fae-c53e-5ea5-8c63-320118d8ee1e"
|
state_hub_workstream_id: "546f2fae-c53e-5ea5-8c63-320118d8ee1e"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -133,11 +133,11 @@ Acceptance: `layer.yaml` `evidence.cadence_status` is no longer
|
||||||
`undeclared`; a missing heartbeat or a count divergence is specified as a
|
`undeclared`; a missing heartbeat or a count divergence is specified as a
|
||||||
finding, not as a log line.
|
finding, not as a log line.
|
||||||
|
|
||||||
## T05 — Wait on consumption ordering; do not implement it
|
## T05 — Implement the assented consumption ordering contract
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: APPROVAL-WP-0001-T05
|
id: APPROVAL-WP-0001-T05
|
||||||
status: wait
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "204bdfeb-a669-569e-a654-10c1a9fcacbc"
|
state_hub_task_id: "204bdfeb-a669-569e-a654-10c1a9fcacbc"
|
||||||
```
|
```
|
||||||
|
|
@ -152,6 +152,17 @@ Acceptance: a written contract both engines have assented to, recorded
|
||||||
here, *then* a consume path. Until then, no `consume` endpoint, no inferred
|
here, *then* a consume path. Until then, no `consume` endpoint, no inferred
|
||||||
consumption from a decision record, no demo that "just consumes on allow".
|
consumption from a decision record, no demo that "just consumes on allow".
|
||||||
|
|
||||||
|
2026-09-01: Gate House resolved the blocker in `GH-DEC-2026-003` and
|
||||||
|
`docs/contracts/approval-consumption.md`, with an explicit State Hub handoff
|
||||||
|
from gate-house (`2ade6044`). Implemented
|
||||||
|
`POST /v1/approvals/{id}/consume`: the PEP presents the PDP decision binding's
|
||||||
|
`request_digest` after ALLOW and before the protected side effect. The SQLite
|
||||||
|
CAS stores the digest and optional decision id; same-digest retries are
|
||||||
|
idempotent success, different digests conflict, and there is no unconsume.
|
||||||
|
The `approval.use` outbox row remains in the same transaction. Concurrency,
|
||||||
|
rollback, API-shape, legacy-database migration, and consume-side T-06 tests are
|
||||||
|
included.
|
||||||
|
|
||||||
## T06 — Durable object, closed state machine, authenticated entries
|
## T06 — Durable object, closed state machine, authenticated entries
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
@ -165,6 +176,9 @@ state_hub_task_id: "44c4997b-6833-5540-a554-0e24210809f2"
|
||||||
CAS supersession (concurrent test), distinct-approver fail-closed, revocation
|
CAS supersession (concurrent test), distinct-approver fail-closed, revocation
|
||||||
without holder cooperation. `_cas_consume` is unexported.
|
without holder cooperation. `_cas_consume` is unexported.
|
||||||
|
|
||||||
|
2026-09-01: the assented T05 contract replaced the private seam with the public
|
||||||
|
digest-bound `consume` mutation and added an in-place SQLite column migration.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Depends on T02 and T03. Implement the object and the machine in SCOPE:
|
Depends on T02 and T03. Implement the object and the machine in SCOPE:
|
||||||
|
|
@ -190,7 +204,7 @@ state_hub_task_id: "ea51499d-ced5-50fa-b852-396719a8c0f4"
|
||||||
```
|
```
|
||||||
|
|
||||||
2026-08-29: `GET /v1/approvals/{id}/claim`. Tests forbid decision-shaped
|
2026-08-29: `GET /v1/approvals/{id}/claim`. Tests forbid decision-shaped
|
||||||
keys and `/v1/check` / `/authorize` / `/consume`. Store unavailable → 503.
|
keys and `/v1/check` / `/authorize`. Store unavailable → 503.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -240,6 +254,10 @@ wrong action, later time, revoked, superseded. Consume-side replay stays out.
|
||||||
Handoff: `docs/flex-auth-handoff.md`. `FLEX-WP-0017` T03 is unblocked on this
|
Handoff: `docs/flex-auth-handoff.md`. `FLEX-WP-0017` T03 is unblocked on this
|
||||||
object; T05 remains blocked only on consumption ordering.
|
object; T05 remains blocked only on consumption ordering.
|
||||||
|
|
||||||
|
2026-09-01: consume-side replay coverage added after `GH-DEC-2026-003`:
|
||||||
|
same-digest retry is idempotent and different-digest replay conflicts. The
|
||||||
|
handoff document now records the PEP-before-side-effect protocol.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Depends on T07, T08, and T05 (the last only for the consume-side replay
|
Depends on T07, T08, and T05 (the last only for the consume-side replay
|
||||||
|
|
@ -251,3 +269,15 @@ Acceptance: `T-06` passes; T03 is unblocked on the object (not on a hub
|
||||||
substitute); T05 remains blocked only on consumption ordering if T05 of
|
substitute); T05 remains blocked only on consumption ordering if T05 of
|
||||||
this workplan is still `wait`, never on a missing object or a missing
|
this workplan is still `wait`, never on a missing object or a missing
|
||||||
digest.
|
digest.
|
||||||
|
|
||||||
|
## Closeout
|
||||||
|
|
||||||
|
Finished 2026-09-01. All nine tasks are complete and the full test suite passes.
|
||||||
|
The first-cut engine now has a durable approval object, authenticated-entry
|
||||||
|
shape, atomic supersession and consumption, claim introspection, transactional
|
||||||
|
outbox, cadence declaration, and complete Canon T-06 replay coverage.
|
||||||
|
|
||||||
|
Residual production hardening and consumer adoption are carried by
|
||||||
|
`APPROVAL-WP-0002`. The unassigned shared Taxonomy request-claim schema is
|
||||||
|
carried by residual intake `APPROVAL-IN-0001`; the local claim continues to
|
||||||
|
yield rather than claiming permanent vocabulary ownership.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
---
|
||||||
|
id: APPROVAL-WP-0002
|
||||||
|
type: workplan
|
||||||
|
title: "Production readiness and consumer adoption"
|
||||||
|
domain: infotech
|
||||||
|
repo: approval-engine
|
||||||
|
status: proposed
|
||||||
|
owner: codex
|
||||||
|
topic_slug: netkingdom
|
||||||
|
created: "2026-09-01"
|
||||||
|
updated: "2026-09-01"
|
||||||
|
origin: residual
|
||||||
|
origin_ref: APPROVAL-WP-0001
|
||||||
|
---
|
||||||
|
|
||||||
|
# APPROVAL-WP-0002 — Production readiness and consumer adoption
|
||||||
|
|
||||||
|
Move the completed first-cut engine spine into an authenticated, durable,
|
||||||
|
observable production service and prove one PEP integration end to end. This is
|
||||||
|
the residual production scope deliberately excluded from APPROVAL-WP-0001.
|
||||||
|
|
||||||
|
The workplan is proposed pending review against the deployment estate and the
|
||||||
|
current key-cape, access-engine, audit-core, and secrets-engine contracts.
|
||||||
|
|
||||||
|
## Authenticate lifecycle mutations and approver evidence
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: APPROVAL-WP-0002-T01
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Bind create, approval-entry, revoke, supersede, and consume callers to
|
||||||
|
authenticated identities. An API-supplied `subject_id`, `actor`, or
|
||||||
|
`decision_id` is provenance only until independently authenticated. Keep
|
||||||
|
authorization decisions in access-engine and approval doctrine in gate-house.
|
||||||
|
|
||||||
|
## Harden durable storage and migrations
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: APPROVAL-WP-0002-T02
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Define the production persistence, backup/restore, migration, concurrency, and
|
||||||
|
recovery posture. Prove schema upgrades preserve existing approvals and that
|
||||||
|
crash recovery cannot separate mutations from outbox evidence.
|
||||||
|
|
||||||
|
## Package and deploy the service
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: APPROVAL-WP-0002-T03
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the governed image/deployment surface, health and readiness behavior,
|
||||||
|
resource bounds, and fail-closed caller configuration. A local WSGI development
|
||||||
|
server is not production evidence.
|
||||||
|
|
||||||
|
## Wire outbox delivery and reconciliation
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: APPROVAL-WP-0002-T04
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Deliver the local outbox asynchronously to audit-core, preserve event-id
|
||||||
|
deduplication, publish lag/depth signals, emit the declared heartbeat, and prove
|
||||||
|
the Gate House reconciliation contract against accepted event counts.
|
||||||
|
|
||||||
|
## Prove one live PEP consumption path
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: APPROVAL-WP-0002-T05
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Integrate one protected-system consumer under `GH-DEC-2026-003`: claim before
|
||||||
|
decision, CAS consume after ALLOW and before side effect, same-digest retry,
|
||||||
|
different-digest conflict, spent-on-failure behavior, and no protected action
|
||||||
|
when approval-engine is unavailable.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue