Runtime
What actually happens, in order, when the application starts, a browser opens a page, a person decides a gate, an agent drafts and a person decides, a release ships or rolls back, a system reads the record, or the record is checked for drift — each as a sequence drawing, and the same facts again in text, naming the real function at every step.
Starting up
create_app refuses to serve on any configuration that has no single right
reading, rather than guessing and being quietly wrong for a year.
Before create_app runs a line of its own code, importing the module tree it
depends on has already loaded the process: flow.load (dpf/domain/flow.py)
reads workflow.yaml, or whatever file DPF_WORKFLOW names, and calls
flow.validate on the result. validate collects every problem at once rather
than stopping at the first — a step whose needs names something defined later,
two steps that produces the same artefact, a phase with no gate or no step
named as its gate, a RACI slot naming a role nobody can hold — and flow.load
raises WorkflowError with the whole list. The application never starts on a
process description that would render a step as done when nothing produced it.
create_app then calls _one_door, the identity check that runs as its own
code rather than as an import side effect. Exactly one of DPF_IDENTITY's
five modes may be in force, and DPF_GATE — the older spelling of site —
must not name a different one. A mode that
needs a setting it was not given refuses rather than running half-configured:
link and site+link without DPF_LINK_KEY, site+link without
ALLOWED_EMAILS, entra without its tenant, client or redirect settings
(entra.missing). DPF_ENTRA_ROLE_MAP is parsed too, and refused if an entry
cannot be read or if it maps no group to a role at all — a workspace that starts
and refuses everybody looks, from outside, exactly like a workspace that is
broken, which is the failure this all exists to avoid. See
Identity for what each of the five modes means.
Past that check, the doors are installed in a fixed order: machine.install
first, always, so that by the time a people door's own hook runs it can see a
request was already admitted by a token and stand aside; then whichever people
door the mode calls for — gate.install for open, site, link or
site+link, or entra.install for entra. open mode installs no hook at
all. Four blueprints follow, in this order: views, api, api_v1, docs.
Separately, create_app itself refuses to start with DPF_ENV=production and
no SECRET_KEY — a session cookie signed with the fallback value is forgeable
the moment sign-in is live.
What create_app does not do is touch the control-plane database. db.init
— the schema's CREATE TABLE IF NOT EXISTS statements, then _migrate adding
any column a table gained since the database was created — runs lazily, inside
the _open hook described below (and, for a token request, inside machine's
own hook), on the first connection a request actually needs. Nothing forces that
to happen before the process starts accepting requests.
A page, end to end
A browser's request crosses the same hooks whatever page it asks for, and the door is checked before any connection to the database opens.
nginx terminates TLS and forwards the request with its mount prefix kept
(DPF_PREFIX); ProxyFix and the _Mounted wrapper in create_app read that
back so url_for builds the right URL. The token door (machine.py) checks
first, and only for /api/v1: elsewhere it sets g.machine to nothing and
stands aside.
Everywhere else, the people door decides whether the request proceeds at
all, before any database connection opens. That hook is whichever
gate.install chose for the mode — _signed_in_only, _link_only or
_either_door — or entra's own. It can redirect to the site's sign-in, or
answer 401 instead for anything that looks like a fetch rather than a browser
tab, and _open never runs. The documentation and the stylesheets need no
door at all (gate.public).
Once past the door, _open opens g.conn (db.init) and pushes stored
settings into the environment (settings.into_environment, which never
overwrites a value a deployment already set). It then decides identity. A
token request becomes service:<name>, with no account and no acting role. On
site, link and site+link the request must also belong to a registered
person: accounts.resolve looks up the account the session points at, and
finding none clears g.principal and redirects to the application's own
sign-in — a second, finer refusal than the door's, because being on the coarse
allowlist is not the same as being a person an administrator added. On open
and entra there is no account table to consult: gate.reader or
entra.reader sets g.principal directly.
g.principal is who is signed in, and nothing inside the application can
change it. g.user (current_user) is which of the four roles they act as.
On open it is a free switch, one click, on the record. On site, link
and site+link it defaults to the signed-in account's own role, and only an
administrator's account may switch it to somebody else's. Under entra there
is no switch at all: the directory's group map decides, and nothing in the
session is consulted. Every four-eyes check reads g.principal, never
g.user; see
A decision at a gate for what that buys. The view then
runs, calling the same pipeline functions the CLI does, and _close commits
g.conn on a clean exit or rolls it back on an exception — so a promotion that
half-happens never happened.
A decision at a gate
A source owner's decision on a section of the data contract v0.1 is the first control any auditor tests: the team that asked cannot be the team that answers.
decide_demand checks two permissions before it looks at the decision at all.
require("demand.decide") (rbac.can) refuses anybody who does not hold the
Source System role, with a 403 naming who does. require_system
(rbac.owns_system) then refuses a source owner answering for a system that is
not theirs — holding the role is not the same as owning the data being asked
about, so the demand's system_key has to match system_key on the acting
role's own row (g.user), not merely the role's name.
Two separate four-eyes checks follow, in this order. same_hand looks up
every principal who sent this exact demand — the audit log's
demand.requested rows for it — and refuses if the signed-in principal is one
of them, whatever role they sent it under: switching role does not make one
person two. Then the view compares the decider's name against
demand["requested_by"], the name recorded when the request was sent, and
refuses an exact, case-insensitive match. Both checks exist because the first
compares identities and the second compares the document's own wording; see
Governance for why four-eyes is built this
way.
Only then does repo.decide_demand write: it updates the demands row —
state, decided_by, conditions — and calls audit.record with action
demand.granted, demand.conditional or demand.denied, naming the decider as
actor and leaving principal to default to g.principal. A release cannot
promote while any source's demand is unanswered, refused, or answered only by
the simulator that walks this flow in a fresh workspace — see
Build, release, promote, roll back.
An agent drafts, a person decides
Drafting a product's requirements is one agent step among several, and every one of them is shaped the same way: grounding in, a proposal out, never a write of its own.
run_step (require("agent.run")) first assembles the grounding:
_grounding reads each scoped source's latest profile and calls
privacy.redact_profile on it before anything is handed to an agent. What that
allows through is one setting, DPF_LLM_DATA (privacy.py): full sends
profiles, six sample rows and column extremes; metadata strips every observed
value, keeping only counts — null rate, distinct count, declared type — and
agents.render_profile adds a sentence saying so, because an agent shown no
sample rows otherwise reads an absent sample as an empty table; off stops the
step before it calls an agent at all. The redaction happens once, in
_grounding, rather than at each of the three agents, because a rule enforced
in three places is a rule with two places left to forget it.
agents.draft_requirements renders the brief and the profiles into one prompt
and calls llm.run, which does three things in order: builds the system prompt
from the pinned agent_versions row (the exact text that was reviewed, not
reassembled here), writes an llm_calls row and commits it before calling
the model — so a call that hangs or a process that is killed still leaves a
record of having asked — and only then calls Anthropic's API with the grounding
and a structured output schema. The response updates that same row with its
tokens, cost and stop reason, win or fail.
Back in run_step, _record_proposal writes a proposals row in state
proposed and appends proposal.made to the audit log. Nothing a model
produces is ever written to requirements directly. A person then either
accepts a chosen subset through accept_requirements — editing any field,
which is what is stored, not what the agent wrote — or ignores the proposal and
calls save_requirement instead. Both routes call the one function,
pipeline.import_requirements, so a hand-written requirement and an accepted
one are indistinguishable to everything downstream. See
Agents for why a proposal is never the record.
Build, release, promote, roll back
Going live is two things inside one transaction — materialise the release's SQL into production, and publish the contract consumers read — and a rollback is the same transaction run against an earlier release, never a deletion.
pipeline.build generates from the contract (domain/generate.py — the same
contract always produces the same SQL, with no model involved), materialises
it to a dev table named for the contract, runs every expectation and checks
conformance, and stores that exact SQL text on the new runs row it started.
cut_release then creates a releases row that points at that run — so a
release always knows exactly which SQL text it was validated against — and
sets it validated or blocked from the run's result. Nothing here is a
judgement; see Components for why pipeline.py is the one
path the CLI and the web share.
approve only moves a validated release to approved; it refuses a release
that is blocked, already live, or missing validation. promote is the
other gate, and the stricter one: release.promotion_gate checks, worst
reason first, that no source's access was refused, declined or only
simulated — a demo answer must never satisfy a real gate — that nothing is
unanswered or unsigned, and that an approval is actually on record. Only once
every one of those holds does promote read the release's own stored SQL (not
a fresh generation) and materialise it into the matching prod table; the same
function then supersedes whatever was live, marks this release live, and
writes the contract's id onto the product's published_contract_id — the
pointer the published interface reads — all inside record_promotion, one
write. On a product's first promotion, repo.publish_contract then gives the
contract its 1.0.0: the 0.x it was agreed as becomes 1.0.0, the releases that carry
it are relabelled, and contract.published keeps the 0.x in the log. The hash does
not move, and a second promotion or a rollback renumbers nothing.
Rollback (pipeline.rollback) refuses a release that was never approved or
never actually live, then calls the identical promote, naming the kind
rollback: the SQL that runs is whatever was stored on that earlier release,
never regenerated, which is what makes a rollback exact rather than
reconstructed. The release it supersedes moves to superseded; nothing is
deleted.
A system reads the record
A service, a script or an agent reads the same record a person sees, through a token that cannot write — on the API directly, over MCP on the same API, or, for an agent running next to the factory, over MCP with no token at all.
Every call to /api/v1 needs Authorization: Bearer dpf_…, whatever the
deployment's identity mode — even open, so an integration never works only by
accident of being unguarded. machine.install's hook hashes the presented
token (tokens.verify, SHA-256, compared in constant time) and looks it up
among live, unrevoked tokens; a match sets g.machine and every door after it
stands aside. There is no scope but read: nothing behind this token can
answer a demand, accept a proposal or promote a release.
Past the door, a plain GET answers from ENDPOINTS — products, releases, the
published contract, open flags, the audit log as a feed to poll — everything a
person can see on a page and nothing they can decide. A POST /api/v1/mcp
instead carries one or more JSON-RPC messages, and mcp_http hands them to
mcp.handle inside mcp.borrowing(g.conn), so the tools read the request's
own connection rather than a second one that could answer from the wrong
database on a deployment that sets DPF_DB in its app config.
The same five tools — products, search, describe, facet, gaps — also
answer on stdio, for an agent running next to the factory rather than calling
it over the network: python -m dpf mcp speaks line-delimited JSON-RPC with no
token at all, trusted because the process is local, and reads the deployment's
default database directly rather than borrowing a request's connection. See
the read API for every endpoint and tool.
Drift
A fixed set of comparisons, none of them a model's opinion, run whenever somebody asks — from a product's Flags page or from the shell — never on their own.
detect_drift is called from POST /p/<slug>/flags/detect (with a warehouse
handle, so the check against what the warehouse actually enforces has
something to read) and from python -m dpf flags PRODUCT on the shell —
nothing in this application calls it on its own. It gathers what each
comparison needs — a source's pinned profile hash against its current one, an
artefact's clean/patched/stale state, the coverage report, whether a
contract has ever been built at all — and passes it to flags.detect, which
runs every comparison and returns a Flag per condition found, identified by a
fingerprint of its code, subject type and subject (never its detail, so a
failing-row count that changes overnight is still the same flag).
repo.sync_flags folds that list against every flag already on the board,
and writes one of three outcomes — never two. A condition never raised
before is raised fresh. One that was resolved and is detected again is
reopened, with its recurrence count moved up, rather than raised as if
new. One that was open or accepted and is no longer detected is resolved
automatically, with the reason "no longer detected". Anything else — still
open or accepted, and still detected — is written nowhere, so a
considered decision is never reset by the next pass. Raising, reopening and
resolving each append to the audit log. The only state a detector never
writes is accepted — a person does that by hand, through decide_flag,
which refuses the call outright without a written reason. See
Governance for why accepted and resolved
are kept apart rather than one "dismissed".