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?"
|
||||
Never "may this actor do X".
|
||||
- Do not implement consumption until `GH-WP-0002-T06` / `APPROVAL-WP-0001-T05`
|
||||
settles the contract with `access-engine`.
|
||||
- Consumption follows `GH-DEC-2026-003` and
|
||||
`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
|
||||
mutation transaction.
|
||||
- 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
|
||||
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
|
||||
failure modes are named, and each needs an owner (standard §9.7.4, §16):
|
||||
The decision precedes consumption, and consumption precedes the protected
|
||||
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 approval consumed twice by racing callers;
|
||||
- an approval consumed after the action it authorized has already failed.
|
||||
|
||||
`approval-engine` performs the mutation, because `access-engine` never mutates.
|
||||
But *who signals consumption, at what point relative to the decision, and what
|
||||
happens on each of the three states above* is a contract between the two engines
|
||||
and is **not yet settled**. It is recorded in the standard's §16, in
|
||||
`GH-WP-0002-T06`, and is required before `FLEX-WP-0017` T05. Raised by
|
||||
`flex-auth`.
|
||||
|
||||
Nothing here may be implemented by guessing that contract.
|
||||
The PEP signals consumption by presenting the decision binding's canonical
|
||||
request digest to this engine. approval-engine performs the CAS before the PEP
|
||||
acts; `access-engine` never mutates. Same-digest retries are idempotent success,
|
||||
different digests conflict, and a failure after consume leaves the approval
|
||||
spent. There is no unconsume.
|
||||
|
||||
## 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
|
||||
as covering a compromised source, or skipping the heartbeat, is the v0.6
|
||||
overclaim this engine must not reintroduce.
|
||||
- **It implements consumption by guessing.** The three races are named and
|
||||
unowned. Code that picks a side is a contract with `access-engine` that
|
||||
`access-engine` has not assented to.
|
||||
- **It diverges from the consumption contract.** PDP mutation,
|
||||
action-before-consume, different-digest reuse, or unconsume violates
|
||||
`GH-DEC-2026-003`.
|
||||
- **It cites observation or containment that has not happened.** Nothing is
|
||||
observed in production, and nothing can be contained automatically
|
||||
(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';
|
||||
7. load-bearing classes declare a heartbeat or reconciliation, and divergence
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
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
|
||||
layer model.
|
||||
|
||||
|
|
@ -29,4 +30,6 @@ make test
|
|||
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.
|
||||
|
||||
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
|
||||
the other side has not assented to.
|
||||
and supplies the approval object and consumption mutation needed by protected
|
||||
systems. Consumption follows the assented `GH-DEC-2026-003` contract.
|
||||
|
||||
## In Scope
|
||||
|
||||
|
|
@ -34,6 +34,8 @@ the other side has not assented to.
|
|||
`superseded / revoked / expired` as terminal exits from `valid`.
|
||||
- Atomic supersession and single consumption (compare-and-swap, never
|
||||
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
|
||||
at the next use.
|
||||
- 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.
|
||||
- PEP shape, unreachable-engine stance maps, Railiance `rail-*` / `rapp-*` /
|
||||
`reef-*` axes. An approval is not a workload (statute §20.1).
|
||||
- Consumption signaling relative to the decision, until `GH-WP-0002-T06`
|
||||
settles it with `access-engine`. Guessing that contract is out of scope
|
||||
even as a prototype.
|
||||
- Any consumption protocol other than `GH-DEC-2026-003`, including PDP
|
||||
mutation, action-before-consume, unconsume, reserve/release, or inferring use
|
||||
from a decision record.
|
||||
|
||||
## 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 graded readiness or a maturity ladder.
|
||||
- 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
|
||||
|
||||
|
|
@ -97,8 +100,8 @@ the other side has not assented to.
|
|||
outbox, WSGI introspection API, claim contract. Not a production deploy.
|
||||
- Layer declaration: INTENT frontmatter + `layer.yaml`. Cadence declared in
|
||||
`cadence.yaml`. No Tooling contacts.
|
||||
- Consumption is not a public API (`APPROVAL-WP-0001-T05` waits on
|
||||
`GH-WP-0002-T06`).
|
||||
- Consumption is public at `POST /v1/approvals/{id}/consume` under
|
||||
`GH-DEC-2026-003`; the endpoint is a lifecycle mutation, never a decision.
|
||||
- Taxonomy request-claim schema is still unassigned; the local claim yields.
|
||||
- Work: `APPROVAL-WP-0001`. Tests: `make test`.
|
||||
|
||||
|
|
@ -158,8 +161,8 @@ type: api
|
|||
title: Approval object lifecycle
|
||||
description: >
|
||||
Create, collect authenticated entries with distinct-approver counting,
|
||||
atomically supersede, and revoke without holder cooperation. No public
|
||||
consume until consumption ordering is settled.
|
||||
atomically supersede, consume with request-digest idempotency, and revoke
|
||||
without holder cooperation.
|
||||
keywords: [approval, state-machine, cas, revocation, supersession]
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -8,13 +8,20 @@
|
|||
|
||||
| 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-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-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-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-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
|
||||
|
||||
|
|
@ -87,8 +87,6 @@ class App:
|
|||
if len(parts) >= 3 and parts[0] == "v1" and parts[1] == "approvals":
|
||||
approval_id = parts[2]
|
||||
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":
|
||||
return 200, self.engine.get(approval_id).as_dict()
|
||||
if rest == ["claim"] and method == "GET":
|
||||
|
|
@ -107,7 +105,14 @@ class App:
|
|||
if rest == ["supersede"] and method == "POST":
|
||||
data = _read_json(environ)
|
||||
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": path}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""SQLite-backed approval object, closed machine, local outbox.
|
||||
|
||||
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
|
||||
can be tested without guessing GH-WP-0002-T06.
|
||||
Consumption follows GH-DEC-2026-003: the PEP presents the decision binding's
|
||||
request digest before the protected side effect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -50,6 +50,9 @@ CREATE TABLE IF NOT EXISTS approvals (
|
|||
expires_at TEXT NOT NULL,
|
||||
required_count INTEGER NOT NULL,
|
||||
superseded_by TEXT,
|
||||
consumed_digest TEXT,
|
||||
consumed_decision_id TEXT,
|
||||
consumed_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
|
@ -109,12 +112,15 @@ class Approval:
|
|||
expires_at: str
|
||||
required_count: int
|
||||
superseded_by: str | None
|
||||
consumed_digest: str | None
|
||||
consumed_decision_id: str | None
|
||||
consumed_at: str | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
entries: list[dict[str, Any]]
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
result = {
|
||||
"id": self.id,
|
||||
"status": self.status,
|
||||
"binding": {
|
||||
|
|
@ -129,6 +135,17 @@ class Approval:
|
|||
"created_at": self.created_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:
|
||||
|
|
@ -172,6 +189,12 @@ class Engine:
|
|||
conn = self._conn()
|
||||
try:
|
||||
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()
|
||||
except sqlite3.Error as exc:
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
|
|
@ -295,6 +318,9 @@ class Engine:
|
|||
expires_at=row["expires_at"],
|
||||
required_count=row["required_count"],
|
||||
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"],
|
||||
updated_at=row["updated_at"],
|
||||
entries=entries,
|
||||
|
|
@ -487,16 +513,51 @@ class Engine:
|
|||
raise
|
||||
return self.get(approval_id)
|
||||
|
||||
def _cas_consume(self, approval_id: str) -> Approval:
|
||||
"""Unexported seam. Do not wire to HTTP. Blocked on GH-WP-0002-T06."""
|
||||
def consume(
|
||||
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()
|
||||
now = iso(self.now())
|
||||
idempotent = False
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute("SELECT * FROM approvals WHERE id=?", (approval_id,)).fetchone()
|
||||
if row is None:
|
||||
conn.rollback()
|
||||
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":
|
||||
conn.rollback()
|
||||
raise Conflict(f"cannot consume from status {row['status']}")
|
||||
|
|
@ -504,9 +565,10 @@ class Engine:
|
|||
conn.rollback()
|
||||
raise Conflict("cannot consume outside validity window")
|
||||
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'",
|
||||
(now, approval_id),
|
||||
(digest, decision_id, now, now, approval_id),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
conn.rollback()
|
||||
|
|
@ -516,7 +578,11 @@ class Engine:
|
|||
"use",
|
||||
approval_id,
|
||||
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()
|
||||
except sqlite3.Error as exc:
|
||||
|
|
@ -525,7 +591,14 @@ class Engine:
|
|||
except Exception:
|
||||
conn.rollback()
|
||||
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(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ classes:
|
|||
action: approval.issuance
|
||||
use:
|
||||
action: approval.use
|
||||
note: "Internal CAS only until GH-WP-0002-T06."
|
||||
note: "Public CAS consumption under GH-DEC-2026-003."
|
||||
supersession:
|
||||
action: approval.supersession
|
||||
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)
|
||||
- Introspection: `GET /v1/approvals/{id}/claim`
|
||||
- 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
|
||||
`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
|
||||
unreachable.
|
||||
|
||||
## What does not exist yet
|
||||
## Consumption contract
|
||||
|
||||
Consumption ordering (`GH-WP-0002-T06` / `APPROVAL-WP-0001-T05`). There is
|
||||
no public `consume`. `FLEX-WP-0017` T05 stays blocked **only** on that
|
||||
contract, not on a missing object or a missing digest.
|
||||
Gate House settled ordering in `GH-DEC-2026-003` and
|
||||
`docs/contracts/approval-consumption.md`. The PEP presents the decision
|
||||
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
|
||||
target, wrong action, later time, revoked, superseded). Consume-side replay
|
||||
is out of scope until T05.
|
||||
target, wrong action, later time, revoked, superseded, same-digest retry, and
|
||||
different-digest consume replay).
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ object mutation.
|
|||
| Class | When | `audit-core` `action` |
|
||||
| --- | --- | --- |
|
||||
| `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` |
|
||||
| `revocation` | object becomes `revoked` | `approval.revocation` |
|
||||
| `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
|
||||
|
||||
|
||||
def test_no_check_or_authorize_or_consume(app):
|
||||
def test_no_check_or_authorize(app):
|
||||
for path in (
|
||||
"/v1/check",
|
||||
"/authorize",
|
||||
"/v1/approvals/00000000-0000-0000-0000-000000000001/consume",
|
||||
"/v1/approvals/abc/consume",
|
||||
):
|
||||
status, body = call(app, "POST", path, {})
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import tempfile
|
||||
import threading
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from approval_engine.errors import Conflict
|
||||
from approval_engine.store import Engine
|
||||
from tests.conftest import approve
|
||||
from tests.conftest import FROZEN, approve
|
||||
|
||||
|
||||
def test_second_supersession_loses(engine):
|
||||
|
|
@ -25,7 +26,7 @@ def test_second_supersession_loses(engine):
|
|||
def test_concurrent_supersessions_one_winner():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "a.sqlite"
|
||||
setup = Engine(path)
|
||||
setup = Engine(path, clock=lambda: FROZEN)
|
||||
obj = approve(setup)
|
||||
setup.close()
|
||||
|
||||
|
|
@ -34,7 +35,7 @@ def test_concurrent_supersessions_one_winner():
|
|||
barrier = threading.Barrier(2)
|
||||
|
||||
def race():
|
||||
eng = Engine(path)
|
||||
eng = Engine(path, clock=lambda: FROZEN)
|
||||
barrier.wait()
|
||||
try:
|
||||
result = eng.supersede(obj.id)
|
||||
|
|
@ -51,20 +52,87 @@ def test_concurrent_supersessions_one_winner():
|
|||
t.join()
|
||||
assert len(winners) == 1
|
||||
assert len(errors) == 1
|
||||
check = Engine(path)
|
||||
check = Engine(path, clock=lambda: FROZEN)
|
||||
assert check.get(obj.id).status == "superseded"
|
||||
check.close()
|
||||
|
||||
|
||||
def test_internal_consume_cas_once(engine):
|
||||
def test_consume_same_digest_is_idempotent(engine):
|
||||
obj = approve(engine)
|
||||
engine._cas_consume(obj.id)
|
||||
try:
|
||||
engine._cas_consume(obj.id)
|
||||
raise AssertionError("double consume must conflict")
|
||||
except Conflict:
|
||||
pass
|
||||
digest = "sha256:" + "ab" * 32
|
||||
first = engine.consume(obj.id, digest, decision_id="decision:first")
|
||||
second = engine.consume(obj.id, digest, decision_id="decision:retry")
|
||||
assert first["idempotent"] is False
|
||||
assert second["idempotent"] is True
|
||||
assert second["decision_id"] == "decision:first"
|
||||
assert [item["class"] for item in engine.undrained()].count("use") == 1
|
||||
claim = engine.claim(obj.id)
|
||||
assert claim["consumed"] is True
|
||||
assert claim["valid_now"] is False
|
||||
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.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):
|
||||
|
|
@ -16,7 +16,7 @@ def test_issuance_queued_in_same_commit(engine):
|
|||
|
||||
|
||||
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)
|
||||
try:
|
||||
eng.add_entry(obj.id, "user:alice")
|
||||
|
|
@ -70,3 +70,15 @@ def test_revocation_event_class(engine):
|
|||
engine.revoke(obj.id)
|
||||
classes = [p["class"] for p in engine.undrained()]
|
||||
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
|
||||
rejects the request.
|
||||
|
||||
Consume-side replay (use twice) waits on GH-WP-0002-T06. This suite covers
|
||||
the object and claim side: wrong binding, expiry, revocation, supersession.
|
||||
Consume-side replay follows GH-DEC-2026-003: same-digest retries are
|
||||
idempotent; a different digest against a consumed object conflicts.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from approval_engine.binding import binding_digest
|
||||
from approval_engine.errors import Conflict
|
||||
from approval_engine.store import Engine
|
||||
from tests.conftest import approve, binding, validity
|
||||
|
||||
|
|
@ -65,3 +66,22 @@ def test_t06_superseded_rejected(engine):
|
|||
obj = approve(engine)
|
||||
engine.supersede(obj.id)
|
||||
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"
|
||||
domain: infotech
|
||||
repo: approval-engine
|
||||
status: active
|
||||
status: finished
|
||||
owner: grok
|
||||
topic_slug: netkingdom
|
||||
created: "2026-08-29"
|
||||
updated: "2026-08-29"
|
||||
updated: "2026-09-01"
|
||||
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
|
||||
finding, not as a log line.
|
||||
|
||||
## T05 — Wait on consumption ordering; do not implement it
|
||||
## T05 — Implement the assented consumption ordering contract
|
||||
|
||||
```task
|
||||
id: APPROVAL-WP-0001-T05
|
||||
status: wait
|
||||
status: done
|
||||
priority: high
|
||||
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
|
||||
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
|
||||
|
||||
```task
|
||||
|
|
@ -165,6 +176,9 @@ state_hub_task_id: "44c4997b-6833-5540-a554-0e24210809f2"
|
|||
CAS supersession (concurrent test), distinct-approver fail-closed, revocation
|
||||
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:
|
||||
|
|
@ -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
|
||||
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
|
||||
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
|
||||
|
|
@ -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
|
||||
this workplan is still `wait`, never on a missing object or a missing
|
||||
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