flex-auth/docs/secrets-engine-action-vocabulary.md
tegwick 68ad039a3f Fix the destroy rule: it was written against an invented claim shape
approval-engine flagged the class one message earlier -- a contract whose
examples contradict its prose gets implemented as its examples -- and
yesterday's package was a fresh instance of it, committed while flagging
it.

The published rule required context.approval.status == "approved" and
counted context.approval.approvals[].subject_id. Neither field exists.
approval-engine's approval_claim.schema.json has `state` (whose operative
value is `valid`, not `approved`) and carries no approver list at all. The
rule was unsatisfiable: every live destroy would have denied
dual_control_required no matter how good the approval was. It failed
closed, so it was never a hole, but it was policy written against a shape
of our own devising rather than a published one.

The rule now consumes valid_now from the real claim, guarded on kind and
issuer. valid_now is the summary predicate that already folds in the
distinct-approver threshold, with reason_code insufficient_approvers for
a claim that failed it -- so this is also the correct layering, not just
the correct shape. Counting approvers here is exactly the duplication
GH-DEC-2026-005 removes; the compensating property is reconstructability
at the issuer under 9.6, which is approval-engine's.

Recorded as a correction section in the package and the vocabulary doc
rather than quietly rewritten. 25 Rego tests and 29 fixtures pass,
covering insufficient_approvers, consumed, revoked, approved-but-not-yet-
valid, foreign issuer, and wrong kind.

Two things the package deliberately does not do, both now written down:
it does not compare binding.pdp_digest, because the request digest is
computed after policy evaluation and a Rego rule cannot see it; and it
makes no cross-check that the claim was approved for this action and
target, because the claim's binding uses approval-engine's vocabulary and
no mapping between the two is published. Inventing one would silently
accept a claim approved for something else. Both belong to the PEP until
a mapping exists, and that is worth closing before SECRETS-WP-0007-T04
makes destroy reachable.

Also swept the other published fixtures on approval-engine's reasoning.
One more instance: the inner decision in examples/caring/action_authorization.json
declared contract_version flex-auth.decision-record.v1 while its
provenance omitted policy_package_digest, registry_snapshot_digest, and
input_claim_digests -- all published contract fields since 2026-09-02.
Completed. The remaining example context vocabularies are consumer-owned
and match their integrations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTbVXpEiXA7mNJVpDnEPcB

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 412054@bnt-lap001
Assistant-Session: 3968fae1-8d59-4209-9bd6-c22594b8ab19
2026-09-06 08:11:14 +02:00

5.6 KiB

secrets-engine action vocabulary

Status: published Date: 2026-09-06 Workplan: FLEX-WP-0021-T01 Source: secrets-engine docs/gated-actions.md, read out of their cli.py Package: secrets-engine.catalog-lane.lifecycle v1 (examples/secrets-engine/policy_package.md)

FLEX-WP-0021-T01 required secrets-engine to name this list rather than letting flex-auth derive it, and made an inferred action a blocker rather than a default. That was not ceremony: four of the twelve would have been inferred wrongly, and one action that looks obvious does not exist at all.

The example vocabulary secrets-engine.lifecycle/v1 this replaces is not this list and was never published (FLEX-DEC-2026-005).

Request shape

Field Value
resource.type secret-catalog-lane
resource.system secrets-engine
resource.id the catalog id
resource.attributes.stage deployment stage
resource.attributes.fields sorted; populated for provision, rotate, verify, exec; empty list otherwise
resource.attributes.policy_targets / auth_targets sorted
context.approval approval claim; required for destroy only

Empty lists are sent rather than omitted or guessed.

The twelve actions

Action Reaches OpenBao Notes
apply yes apply --dry-run does not reach the gate
provision yes carries fields
rotate yes carries fields
verify yes carries fields
handoff yes
wrap yes
exec yes carries fields
deactivate yes also the gate for the CLI verb revoke
suspend yes
destroy yes dual control; not reachable live yet
compromise no local delivery overlay only
reactivate no local delivery overlay only

Four things an inferred list gets wrong

1. revoke is not an action. The CLI verb revoke gates as deactivate, which is also reached from lifecycle deactivate. There is no revoke value and one must not be added. The package asserts this (test_revoke_is_not_an_actionunknown_action).

2. destroy is defined but not reachable live. Its handler raises before the gate, so only --dry-run renders. It stays in the vocabulary as the dual-control case; no live destroy Check arrives until SECRETS-WP-0007-T04 lands. Keeping it in the package now means the rule is reviewed and fixtured before the path opens rather than in the same change that opens it.

3. compromise and reactivate touch no OpenBao object. They mutate local delivery-overlay state only. They are gated because they change delivery posture, not because they write to the backend — a distinction worth recording, because a reviewer looking for backend writes would otherwise read their presence as a mistake.

4. Seven CLI surfaces never reach the gate and must not appear in the package: plan, apply --dry-run, route, audit, catalog, decision inspect, and evidence.

apply --dry-run is the one flex-auth cannot enforce. apply is an action and apply --dry-run is not, but both would arrive as apply — the distinction is invisible to the PDP. What keeps them apart is that the PEP does not call the gate for a dry run. flex-auth records the limit rather than implying a control it does not have.

Dual control on destroy

destroy requires an approval-claim on context.approval, issued by approval-engine, whose valid_now is true. That is the whole check.

The shape is approval-engine/schemas/approval_claim.schema.json. valid_now is a summary predicate — true only when the object is approved, inside its validity window, and not consumed, superseded, revoked, or expired — and the distinct-approver threshold is folded into it. A claim that failed the threshold returns reason_code: insufficient_approvers.

flex-auth consumes the claim as an input claim and never mutates it (security-layer-model_v0.7 §9.4). Per FLEX-DEC-2026-006 the approval fact is approval-engine's step-1 artifact and the decision is step 2; the PEP validates across both.

The package does not count approvers, re-derive validity/freshness/ signature/supersession, or compare binding.pdp_digest. Counting approvers is the duplication the split removes (GH-DEC-2026-005); the compensating property is reconstructability at the issuer under §9.6, which is approval-engine's. The digest comparison is real and preferred but the request digest is computed after policy evaluation, so a Rego rule cannot see it — it belongs in the PEP.

Correction, 2026-09-06

The first published rule required context.approval.status == "approved" and counted context.approval.approvals[].subject_id. Neither field exists: the claim's field is state (and valid, not approved, is the operative value), and it carries no approver list. The rule was unsatisfiable — every live destroy would have denied dual_control_required regardless of the approval. It failed closed, so it was never a hole, but it was a policy written against an invented shape. Corrected the same day.

Open item: no published binding mapping

The claim's binding.action and binding.target use approval-engine's vocabulary (secrets.kv.destroy, {"id": ..., "stage": ...}), not this one (destroy, lane:...). No mapping between them is published, so the package makes no cross-check that the claim was approved for this action and target, and must not invent one — a wrong mapping would silently accept a claim approved for something else. Until a mapping exists that correspondence is the PEP's, via binding.pdp_digest. Worth closing before SECRETS-WP-0007-T04 makes destroy reachable.