Project: pdf-ops Started: 2026-08-31 Last updated: 2026-08-31
This document defines the format and workflow for tracking architectural decisions in this repo. Companion files: DECISIONS.md (the data), planning/ASSUMPTIONS.md (open questions). CI validation: scripts/validate_decisions.py.
The controlled vocabularies described in prose throughout this document are formalized in the machine-parseable section at the bottom - scripts/_standard_parser.py reads from there, and validate_decisions.py imports the resulting frozen sets. Edit the bottom section to change vocabularies; the rest of the document is human-readable companion prose.
Every decision has a stable primary key of the form D-### (zero-padded 3-digit sequential).
D-001through the last row in the Master Register are the authoritative IDs.- New decisions get the next available number. IDs are never re-used, never renumbered.
- ADRs (full Context / Alternatives / Consequences analysis) carry a dual ID:
D-###is the master ID,ADR-###is the ADR-specific ID. The mapping is documented in the master register.
Why D-###: grep-friendly, sorts lexicographically, unambiguous vs Markdown #N headings, stable under edits, 7 characters fits cleanly inline.
| Icon | Status | Meaning |
|---|---|---|
| 🟢 | Decided | Committed. Being implemented or already implemented. |
| 🟡 | Pending | Awaiting external input (measurements, a spike, an upstream answer). Has a tracked owner and unblock condition. |
| ⏸ | Deferred | Explicitly parked. Non-breaking to add later. Includes a phase_trigger - the observable condition that would reopen it. |
| 🔵 | Superseded | Replaced by another decision. Row stays for historical record. superseded_by points forward. |
| ⚪ | Draft | Being proposed, not yet committed. Used during a walkthrough before approval. |
| ❌ | Rejected | Considered and declined. Kept so we don't re-debate it. Includes a one-line reason. |
Every decision row in the master register carries these 7 required fields, which appear as columns in the master table AND as field lines in the per-decision anchor page:
| Field | Purpose | Example |
|---|---|---|
id |
Stable primary key | D-042 |
status |
Lifecycle state | 🟢 Decided |
area |
Subsystem / concern (fixed taxonomy, see below) | error-handling |
title |
Short human name | Static client error messages |
summary |
One-line decision content | Client-facing error_message is a static template; raw exceptions only in DLQ + observability platform. |
decided |
ISO date when the decision was confirmed. For 🟡 Pending and ⏸ Deferred entries, this is the date the deferral was confirmed (not a future date). | 2026-04-07 |
discussion |
Link to the full discussion - subsystem doc only (no ADR link, no -). Every decision must have a durable home somewhere in docs/. |
DESIGN_NOTES.md section 2 |
| Field | Purpose | When present |
|---|---|---|
adr |
Link to a full ADR section | For decisions warranting full Context / Alternatives / Consequences analysis |
owner |
Who drives the unblock | Required for 🟡 Pending |
phase_trigger |
Observable condition that would unblock (🟡) or reopen (⏸) | Required for 🟡 Pending and ⏸ Deferred |
supersedes / superseded_by |
Forward/backward pointers between evolving decisions | When a decision replaces or is replaced by another |
amends / amended_by |
Partial-update relation - the decision still stands but a parameter has been tuned | Retune scenarios (distinct from supersedes) |
related |
Other D-### IDs that relate to this one |
Cross-references |
blocks / blocked_by |
Dependencies | When a decision cannot be implemented until something else lands |
revisit_in |
Planned revisit milestone | For decisions marked "Phase N retune candidate" or similar |
revisit_trigger |
The observable condition that prompts re-evaluation | Required for decide-under-assumption (see workflow below) |
implementation_phase |
Which phase the decision lands in | Planning / scheduling lens |
risk |
Impact of being wrong: low / medium / high |
Planning lens - informs review depth and test coverage |
reversibility |
Cost to undo: cheap (env-var) / expensive (code + data) / one-way-door (breaking contract) |
Planning lens - informs how much up-front certainty is worth chasing |
Schema rules:
discussionmust NOT be-. Every decision has a home somewhere indocs/. If the home is shared with other decisions, link to the file and cite the section heading closest to the relevant prose.adris separate fromdiscussion. A decision can have both, onlydiscussion, or neither.decidedis a real date for every entry - never blank, never TBD.
The fixed area taxonomy is enumerated in the Controlled vocabularies (machine-parseable) section at the bottom of this document. Each area's intent is described inline in the codebase by the docs that own it - areas don't have stand-alone definitions. Edit the machine-parseable section to add or remove areas; validate_decisions.py picks up the change automatically.
- Propose - during discussion, write the decision inline in the relevant subsystem doc as a ⚪ Draft with rationale.
- Assign ID - once approved, take the next
D-###(check the master register for the last used ID). - Register - add a row to the master table with the required fields.
- Anchor page - add a per-decision anchor page below the table with the full fields.
- Link - add inline
[D-###](DECISIONS.md#D-###)breadcrumbs wherever the decision affects code or spec. - ADR (optional) - if the decision warrants full Context / Alternatives / Consequences analysis, write an ADR section. Use a dual ID (
D-### / ADR-###). ADR numbering starts atADR-001.
There are two change operations and they mean different things. Pick the right one:
| Operation | When to use | Old row status | New row status | Breadcrumb behavior |
|---|---|---|---|---|
Supersede (supersedes / superseded_by) |
The architectural choice itself changes (approach, provider, contract). The old decision is no longer correct. | 🔵 Superseded | 🟢 Decided | Update to point to the new ID |
Amend (amends / amended_by) |
The approach stands, but a parameter is tuned (cap value, threshold, retry count). Original decision is still correct. | 🟢 Decided (unchanged) | 🟢 Decided | Stay on the original ID for historical continuity; the original's anchor page lists all amended_by IDs |
Never edit a row's content in place. History matters - both supersede and amend create a new D-###.
Scenario: D-002 chose pypdf as the PDF engine. Later, large-file memory limits force a move to the qpdf-backed pikepdf, changing the dependency set and the container image. This is an architectural change, not a tuning.
Step 1 - add a new decision, e.g. D-047, with supersedes: D-002:
### D-047
- **Title:** PDF engine - switch to pikepdf
- **Status:** 🟢 Decided
- **Area:** pdf-engine
- **Decided on:** 2026-10-15
- **Summary:** Replace pypdf with pikepdf behind the engine seam for both operations.
- **Risk:** high
- **Reversibility:** expensive
- **Supersedes:** [D-002](#D-002)
- **Where:** [`DESIGN_NOTES.md section 1`](DESIGN_NOTES.md)Step 2 - flip the old row 🔵 Superseded in the master register and add superseded_by to the D-002 anchor page.
Step 3 - update inline breadcrumbs in code and docs from [D-002](DECISIONS.md#D-002) to [D-047](DECISIONS.md#D-047). The D-002 row stays in place for history.
Scenario: measurements show a merge input-count cap of 50 (D-024) is safe to raise to 100. The cap still exists, the approach is unchanged, only the number differs. This is an amendment, not a supersede.
Step 1 - add a new decision, e.g. D-098, with amends: D-024:
### D-098
- **Title:** Merge input-count cap retuned to 100
- **Status:** 🟢 Decided
- **Area:** config
- **Decided on:** 2026-08-01
- **Summary:** Raise the merge input-count cap from 50 -> 100 based on measurements (p95 well under the cap).
- **Risk:** low
- **Reversibility:** cheap
- **Amends:** [D-024](#D-024)Step 2 - leave D-024 🟢 Decided (not superseded). Add an Amended by line to its anchor page.
Step 3 - inline breadcrumbs continue to point to D-024 for historical context, because the reasoning is still there.
Rule of thumb: if the why from the original anchor page is still valid, you're amending. If the why has to be rewritten, you're superseding.
Use ❌ Rejected status with a one-line reason in the Summary cell. Don't delete - the whole point is to avoid re-debating.
Scenario: someone proposes adding Component X. After evaluation, an existing component already handles the use case - no new infra justified.
Master register row (the Summary cell carries the one-line reason):
| [`D-111`](#D-111) | ❌ | infra | Component X for use case Y | 2026-06-01 | Rejected - existing Component Z handles this; no additional infra needed. | [`planning/INFRA_CHECKLIST.md section 5`](planning/INFRA_CHECKLIST.md) | - |Anchor page (minimal - no need for rationale beyond the rejection reason):
### D-111
- **Title:** Component X for use case Y
- **Status:** ❌ Rejected
- **Area:** infra
- **Decided on:** 2026-06-01
- **Summary:** Rejected - existing Component Z handles this; no additional infra needed.
- **Where:** [`planning/INFRA_CHECKLIST.md section 5`](planning/INFRA_CHECKLIST.md)Do not fill in Risk / Reversibility / Implementation phase - the decision was rejected, those fields are nonsensical. The row stays for the record.
When a decision must be made while a key input is still unconfirmed, mark it 🟢 Decided rather than 🟡 Pending - but flag the assumption explicitly. This unblocks implementation while preserving auditability.
Status string format: 🟢 Decided - assumed YYYY-MM-DD (revisit if X)
Required anchor-page field: Revisit trigger: - the observable condition that would prompt re-evaluation.
Required cross-link: A matching entry in planning/ASSUMPTIONS.md section 1 (Assumptions to Validate) with the same revisit trigger and a Where link back to the decision's anchor.
Why prefer 🟢 over 🟡: a 🟡 Pending decision blocks implementation - it has no committed direction. A 🟢-with-assumption commits to a default and unblocks work, while transparently flagging the input that may invalidate it. Implementation proceeds against the default; if the assumption is later disproved, an amendment is added (using the amends workflow above) - not a supersede, because the original reasoning was correct given the assumption.
Validator gate: scripts/validate_decisions.py enforces that every decision whose Status contains the substring assumed has the Revisit trigger: field populated AND a corresponding row in planning/ASSUMPTIONS.md section 1.
Scenario: feature X needs a cap value. Telemetry doesn't yet exist to set it empirically. Block on telemetry would delay implementation by weeks; pick a reasonable default and revisit post-launch.
Master register row:
| [`D-060`](#D-060) | 🟢 | config | Merge input-count cap | 2026-04-20 | **Assumed 50.** Observed workflows fan in 2-10 inputs; 50 gives headroom without inviting memory blowups. Revisit if a real workflow exceeds it. | [`planning/ASSUMPTIONS.md section 1 item 13`](planning/ASSUMPTIONS.md) | - |Anchor page:
### D-060
- **Title:** Merge input-count cap
- **Status:** 🟢 Decided - assumed 2026-04-20 (revisit if real workflows exceed it)
- **Area:** config
- **Decided on:** 2026-04-20
- **Summary:** **Assumed 50.** Unbounded input lists risk memory blowups; observed workflows fan in 2-10 inputs. Env-configurable. Revisit if a real workflow exceeds it.
- **Risk:** low
- **Reversibility:** cheap
- **Revisit trigger:** A workflow legitimately needing more than 50 inputs, or measurements showing headroom.
- **Where:** [`planning/ASSUMPTIONS.md section 1 item 13`](planning/ASSUMPTIONS.md)ASSUMPTIONS.md section 1 row:
| 13 | Merge input-count cap = 50 | **ASSUMED 2026-04-20.** ... See [D-060](../DECISIONS.md#D-060). | A real workflow exceeding the cap | Raise via env var | - (non-blocking) |Trigger: when 5+ decisions reach 🔵 or ❌ status (combined), split the master register into two tables in DECISIONS.md:
- Active Decisions -
🟢,🟡,⏸,⚪ - Historical Decisions -
🔵,❌, with a one-line intro linking back to the active table
Both tables stay in DECISIONS.md. The split keeps the active view scannable while preserving the audit trail. Order is not sacred - ID order is fine for both.
No automation needed - do it manually when the threshold trips. validate_decisions.py continues to verify counts against whichever table each row lives in.
Every reference to a decision from another doc follows this exact pattern:
Some prose about a design choice (per [D-042](DECISIONS.md#D-042)).- The link text is only the ID - no prose inside the brackets.
- This keeps
grep -r "D-042"trivial. - Use a relative path appropriate to the citing file's depth (
DECISIONS.md#D-042fromdocs/,../DECISIONS.md#D-042fromdocs/<sub>/,../../DECISIONS.md#D-042fromdocs/<sub>/<sub>/).
This section is parsed by
scripts/_standard_parser.py.validate_decisions.pyimports vocabularies from here - it does NOT carry hardcoded duplicates. Edit the backtick-quoted items below to change vocabularies; the validator picks up changes automatically. Do not change the H2 header wording or the H3 subsection headers. Each item must be on its own line beginning with- `<value>`.
pdf-engineconfigerror-handlingsecurityobservabilityreliabilitycontainertestinginfraproject
🟢🟡⏸🔵⚪❌
lowmediumhigh
cheapexpensiveone-way-door
01234567