Docs / Architecture / Quality, risks and technical debt
Explanation For engineers

Quality, risks and technical debt

Five goals this design optimises for, ranked; the tests that show each one holds today; and the risks and technical debt an engineer should weigh before changing any of it.

Quality goals

  1. Auditability — one record, every decision named and timed, nothing in it edited or deleted once it is written (audit.py; db.py states the rule that requirement_revisions, approvals, promotions and audit_log are never updated or deleted, and tests/test_append_only.py holds the audit log to it). The point is that "who approved this, and when" has exactly one place to look, including who was actually signed in when a gate closed, not only which role closed it — see Governance. The cost: the record only grows, a mistake sits beside its correction forever, and a write that cannot be recorded does not happen at all — audit.record fails the transaction it belongs to rather than letting the action through unlogged.

  2. Determinism — the same accepted contract produces byte-identical SQL every time, with no model between the two (domain/generate.py; decision 0001). That is what makes the audit trail mean something: "what produced this table" has one checkable answer. The cost: anything the contract's schema cannot yet say needs the schema to grow before it can ship, which is slower than writing the SQL by hand, deliberately. The boundary is exact: an agent may draft a contract non-reproducibly (Agents); nothing downstream of an accepted contract is allowed to share that property.

  3. Failing closed — identity, the workflow and the application's own start-up refuse rather than guess whenever they are told two contradictory things, or not told enough to decide (web/gate.py, web/entra.py, domain/flow.py). A deployment that would otherwise run for a year on a guessed identity story, or a process file that is subtly out of order, stops instead at the moment somebody is looking at it. The cost: availability. A mistyped environment variable, or one step out of order in workflow.yaml, stops the application rather than letting it start in a degraded mode — getting configuration exactly right becomes a precondition of running at all, not a nice-to-have.

  4. Portability across warehouses — one seam, warehouse.py, carries the whole application over either of two engines, and nothing above it changed when the second one arrived (decision 0002; The warehouse). A customer's choice of warehouse is a connection string, not a rewrite. The cost: portability stops at generated SQL. A contract's hand-authored mapping expressions are copied through untranslated, a few types have no faithful equivalent on the other side, and the seam itself is one dispatch rather than a plugin framework — a third engine grows that one if rather than slotting into an abstraction built to expect it.

  5. Operability on one small box — the control plane is one SQLite file, the warehouse is either a disposable local file or one external connection, and the application runs as two gunicorn workers with no container and no build step (see decision 0003). One person can run the whole thing with no database server to administer and no cluster to reason about, and a redeploy is a restart. The cost: a hard ceiling on write concurrency. SQLite admits one writer at a time; WAL and a busy_timeout queue the second worker rather than erroring, but do not parallelise it. A deployment with many concurrent writers needs Postgres behind db.py — the exit decision 0002 already names, rather than a surprise somebody meets in production.

Quality scenarios

Evidence, not aspiration: every response described is current behaviour, and every test named passes today.

Goal Scenario Response Evidence
Auditability A signed-in person switches role mid-flow and tries to close the second of two gates on their own request. Refused: four-eyes compares the signed-in principal, not the acted role, so switching role cannot manufacture a second approver. tests/test_gate.py::test_switching_role_does_not_let_you_answer_your_own_request
Auditability A product is archived, or any code path tries to delete directly from the audit table. Neither succeeds: no code path issues DELETE FROM audit_log, and archiving a product leaves every event it wrote with its exact text — only the row's pointer to the now-gone product is cleared. tests/test_append_only.py::test_nothing_in_the_application_deletes_from_the_audit_log, tests/test_append_only.py::test_tidying_a_product_away_cannot_remove_what_it_did
Auditability A requirement has one acceptance criterion that no expectation verifies. Marked uncovered, with a sentence saying checks are composed into the data contract, and a link straight to where that agreement is written — not silence, and not a dead end. tests/test_requirement_to_test.py::test_a_criterion_nothing_tests_says_where_a_check_would_be_written
Determinism The same accepted contract is handed to generate.py twice, on different machines or different days. Byte-identical SQL both times; no model, clock or environment enters the derivation. tests/test_generate.py::test_generation_is_byte_identical
Determinism A use case team accepts a draft contract — the agreement gate — moving its state from draft to accepted. The SQL the contract generates is unchanged by the acceptance: a gate changes who stands behind the artefact, never what it computes. tests/test_generate.py::test_accepting_a_draft_changes_neither
Failing closed DPF_IDENTITY names a mode that does not exist, or DPF_GATE is set alongside a different DPF_IDENTITY. create_app raises at start-up, naming the contradiction, rather than picking one reading and running with it for a year. tests/test_entra.py::test_an_unknown_mode_refuses_to_start, tests/test_entra.py::test_two_doors_refuse_to_start
Failing closed workflow.yaml (or a file named by DPF_WORKFLOW) declares a step that depends on one defined later in the file. Loading the workflow — which happens on import, before anything else runs — raises WorkflowError, naming the out-of-order dependency and every other problem the file has, at once. tests/test_workflow_config.py::test_a_later_dependency_is_named_as_such, tests/test_workflow_config.py::test_every_problem_is_reported_at_once
Failing closed A deployment asks for the site door (DPF_GATE=1) but never sets ALLOWED_EMAILS, so nobody is named as allowed in. Every request gets a 503 rather than being let through: the application cannot tell a reader from a stranger, and treats "cannot tell" as closed, not open. tests/test_gate.py::test_closed_and_unconfigured_refuses_rather_than_opens
Portability across warehouses The same source table is profiled once through the DuckDB adapter and once through the Databricks adapter. The two profiles agree key for key: everything above warehouse.py reads one shape regardless of which engine answered. tests/test_warehouses.py::test_the_two_adapters_produce_the_same_profile_body
Portability across warehouses A build is attempted against a Databricks connection holding a SELECT-only token — a read-only pilot. Refused before any statement is sent, naming exactly the grant that is missing, rather than a driver error several steps later. tests/test_warehouses.py::test_read_only_databricks_refuses_a_build_and_names_the_grant
Operability on one small box The control-plane database is initialised on a box where it has already been initialised — a restart or a redeploy. Running init again is safe: it is a no-op on an existing database, and every row already in it survives untouched. tests/test_db.py::test_init_is_idempotent
Operability on one small box An operator's systemd unit sets a configuration value as an environment variable, and somebody also types a value into the same field on the Connections page. The environment wins; the page shows the field read-only and names where its value came from, so the browser cannot quietly override what the box itself was told. tests/test_settings.py::test_a_locked_setting_is_shown_read_only_and_says_where_it_came_from

Risks and technical debt

Four items, each real today. Known limitations has the rest — what the process does not do yet, and where a control does not yet reach as far as its name suggests.

  • Five integrations are written, not proven. Databricks — and, through the same connection, Unity Catalog — the Informatica catalogue, ServiceNow and both delivery trackers are built to their vendor's published API and tested on the SQL, bodies or requests they produce; none has yet authenticated against a live tenant (connections.py deliberately calls this mock-up rather than "unverified" or "pending"). The same status reaches further into the Databricks connection than the badge alone suggests: the quality push only ever sends the constraint set, and prints the DLT module and monitor definitions for somebody to install by hand, so drift is read back for constraints and not for the other two — see The warehouse. Consequence: a hostname typo, a missing grant or an API shape that has moved on would surface against a real tenant, not in CI. What retires it: running each one, once, against a tenant — the caveat the Connections page already shows next to each ("Never run against a live tenant") stops applying the day that happens.

  • The control plane is one writer. WAL and a 30-second busy_timeout let gunicorn's two workers queue for the SQLite file rather than fail with database is locked (db.py), but queueing is not concurrency. Consequence: a deployment with many simultaneous writers waits on the lock instead of scaling out — a deliberate ceiling (decision 0002), not an oversight, but a ceiling nonetheless. What retires it: Postgres behind db.py, the exit decision 0002 already names.

  • Portability stops at generated SQL. domain/dialect.py translates identifiers and declared types between engines; a contract's own hand-authored mapping expressions are copied through verbatim, and TIME, UUID, JSON and an unbounded VARCHAR have no faithful equivalent on the far side — see The warehouse. Consequence: an assumption that quietly holds on DuckDB can be silently wrong on Databricks until somebody profiles the result; nothing in the pipeline checks it for them. What retires it: holding authored expressions to a portable subset the way generated SQL already is, or routing them through dialect.py the way identifiers and casts already are.

  • A hand-edit is a sanctioned bypass of determinism, not a forbidden one. patch_note lets somebody fix what generate.py could not produce, and the artifact is marked patched rather than hiding the edit (decision 0001). Consequence: every patch is a confession that the contract's schema cannot yet say something, and a product that collects several is quietly becoming hand-maintained SQL wearing a contract's name. What retires it: reading the patches that actually accumulate, and growing the contract schema to express the ones that recur.

Documentation for version 0.2.0 Reviewed 2026-10-19 Covers dpf/domain/generate.py, dpf/web/gate.py, dpf/web/entra.py, dpf/domain/flow.py, dpf/audit.py, dpf/db.py, dpf/warehouse.py, dpf/connections.py, tests/test_generate.py, tests/test_gate.py, tests/test_entra.py, tests/test_workflow_config.py, tests/test_append_only.py, tests/test_requirement_to_test.py, tests/test_warehouses.py, tests/test_db.py, tests/test_settings.py