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
-
Auditability — one record, every decision named and timed, nothing in it edited or deleted once it is written (
audit.py;db.pystates the rule thatrequirement_revisions,approvals,promotionsandaudit_logare never updated or deleted, andtests/test_append_only.pyholds 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.recordfails the transaction it belongs to rather than letting the action through unlogged. -
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. -
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 inworkflow.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. -
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-authoredmappingexpressions 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 oneifrather than slotting into an abstraction built to expect it. -
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_timeoutqueue the second worker rather than erroring, but do not parallelise it. A deployment with many concurrent writers needs Postgres behinddb.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.pydeliberately calls thismock-uprather 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_timeoutlet gunicorn's two workers queue for the SQLite file rather than fail withdatabase 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 behinddb.py, the exit decision 0002 already names. -
Portability stops at generated SQL.
domain/dialect.pytranslates identifiers and declared types between engines; a contract's own hand-authoredmappingexpressions are copied through verbatim, andTIME,UUID,JSONand an unboundedVARCHARhave 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 throughdialect.pythe way identifiers and casts already are. -
A hand-edit is a sanctioned bypass of determinism, not a forbidden one.
patch_notelets somebody fix whatgenerate.pycould not produce, and the artifact is markedpatchedrather 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.