Approval¶
Approval is a formal accept or reject decision by an eligible reviewer
on one exact content revision.
Decisions are stored in the database, never in the Markdown files
(0007: Approval history lives in the database, append-only).
The rules live in app.domain.approval (pure),
app.services.decisions (the write path) and
app.services.approval_view (the detail page’s approval block).
The policy¶
There is one global policy, the approval_policy section of
catalog.yaml:
approval_policy:
id: global
version: 1
required: true
reviewers:
users: ["https://idp.example.com|subject-123"] # issuer|subject
roles: [adr-reviewer]
allow_self_approval: false
idandversionRecorded with every decision, so history shows which policy applied.
requiredfalsemeans approval is not needed anywhere (statenot_required).reviewersA caller is eligible when their
issuer|subjectis listed inusersor one of their token roles is listed inroles(app.domain.approval.is_eligible). Eligibility is independent of RBAC:readnever implies review, and being a reviewer grants noreadoroperate. (A reviewer still needsreadto reach the decision endpoint.)allow_self_approvalWhen
false, a reviewer may not decide a record they authored; see Self-approval.
A missing or invalid policy does not stop startup.
It gives every record the state policy_missing,
is reported by /healthz and the UI,
and every decision is refused until it is fixed.
It is never treated as “approval not required”.
States¶
The state belongs to a record’s current digest,
evaluated from all decision events of its logical identity
(source_id, project, record_key).
app.domain.approval.evaluate applies these rules in order:
flowchart TD
start(["record with current digest"])
p{"policy configured<br/>and valid?"}
r{"policy required?"}
e{"decision for this<br/>identity and digest?"}
a{"that decision<br/>is accepted?"}
o{"any acceptance of<br/>another digest?"}
pm["policy_missing<br/>needs approval"]
nr["not_required<br/>no approval needed"]
ac["accepted<br/>no approval needed"]
rj["rejected<br/>needs approval"]
cs["changed_since_acceptance<br/>needs approval"]
pe["pending<br/>needs approval"]
start --> p
p -- "no" --> pm
p -- "yes" --> r
r -- "no" --> nr
r -- "yes" --> e
e -- "yes" --> a
a -- "yes" --> ac
a -- "no" --> rj
e -- "no" --> o
o -- "yes" --> cs
o -- "no" --> pe
The “Needs approval” filter returns every state except accepted and
not_required.
app.storage.postgres.search.approval_join mirrors the same rules in
SQL so the filter paginates correctly;
tests/storage/test_approval_parity.py keeps the two in step.
approvable does not change the state.
A record without a valid UUID, or with bad encoding, shows pending
(or changed_since_acceptance) with a reason and cannot be decided;
the UI labels it “Blocked”.
Candidates, published rows and promotion¶
Approval always evaluates a stored revision, never a live source read. For a published row that is the latest successfully polled content, so the UI shows its freshness next to the state (see Freshness).
- Acceptance before publication
A reviewer may accept a candidate while it is still on a feature branch; the UI shows “Accepted — candidate” with its ref. An accepted candidate is authoritative for implementation.
- Promotion
Decisions are keyed by identity and digest, not by row. When a poll later finds the same digest on the published ref, the published row is
accepted(“Accepted — published”) with the full history. Nothing is copied or re-decided.- Divergence
If the published digest differs from an accepted candidate’s, both rows stay. The published row shows
changed_since_acceptanceand a divergence notice linking to the accepted revision; a candidate whose published sibling differs links to it too. Acceptance never moves across digests.- Competing candidates
Several candidates of one identity on different refs are separate rows with independent states. The detail page lists the others with their states; deciding one never affects another digest.
- Across projects
The same UUID in another project or registration is another identity (flagged
duplicate_uuid); promotion never crosses it.
Recording a decision¶
POST /api/v1/records/{id}/decisions with
{action, comment?, expected_digest?}
(comment up to 2,000 characters).
app.services.decisions.decision_blocker checks, in order,
and the same function gives the detail page its can_decide flag
and reason:
the record exists, else 404
record_not_found;the policy is configured, else 409
policy_missing;the policy requires approval, else 409
approval_not_required;the caller is eligible, else 403
not_eligible;the record is approvable, else 409
not_approvable;it is not a self-approval, else 403
self_approval;expected_digest, when sent, equals the current digest, else 409digest_changed(a reviewer never decides content they did not see);no decision exists yet for this identity and digest, else 409
already_decided.
The event is appended with ON CONFLICT DO NOTHING against
uq_decision_event_identity_digest, so two concurrent decisions on
one digest produce exactly one event and one 409.
The server sets the event id and created_at;
the event also stores the actor’s issuer, subject,
display name and email, the policy id and version,
and provenance (record id, kind, ref, path and source URL at decision
time).
One decision per digest means a rejection is final for that content.
Changed content has a new digest and a fresh pending or
changed_since_acceptance state.
Self-approval¶
With allow_self_approval: false, a decision is refused when the
record’s author equals the reviewer’s verified email
(case-insensitive) or the reviewer’s issuer|subject.
author is known only for candidates whose CI push supplied it.
Neither source adapter provides one:
the filesystem has no such concept,
and the GitLab adapter does not fetch per-file commit authors
(it would cost one slow request per changed file).
The check therefore cannot block self-approval of polled records.
This is a documented limit of the policy, not a security guarantee.
Diffs¶
GET /api/v1/records/{id}/diff (app.services.diff) is a unified
diff between two stored revisions, labelled with both digests.
The default base is the identity’s last decided revision other than the
current one (latest acceptance, else latest rejection,
app.domain.approval.last_decided),
else the row’s previous_digest,
else there is no prior revision.
Only digests related to the record can be requested;
any other digest answers 404 unknown_revision,
so the endpoint cannot read arbitrary snapshots.
Freshness¶
A registration is stale when it has never polled successfully,
its last poll failed or was partial,
or its last successful poll is older than
ADRIANO_STALE_AFTER_MINUTES (default 120)
(app.services.sources_view.is_stale).
The UI then shows a banner next to a published record’s state and never
claims that the indexed revision is the current published content.
The approval state itself is still evaluated normally against what was
last indexed.
Errata¶
An erratum is approved exactly like a decision, on its own identity and digest; the corrected decision is never touched. The UI words the states as a report:
pendingandpolicy_missing: Reportedaccepted: Verifiedrejected: Rejectedchanged_since_acceptance: Changed since verificationnot_required: Published (no review required)
See Errata for how errata are counted on a decision.