Docs / Architecture / Runtime
Explanation For engineers

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.

IF two settings disagree, or one mode is incompleteELSE exactly one identity mode, fully configuredOperatorserve, orgunicorndpf/web/__init__.pycreate_appdpf/domain/flow.pyThe workflowdpf/web/machine.py, dpf/web/gate.py, dpf/web/entra.pyThe doorsthe token door, then thepeople doorcreate_app()load the workflowphases and steps, or anerror_one_door()RuntimeError, namingwhat is wronginstall the token door firstinstall the people door, or Entra'sregister theblueprintsa configured WSGI appthe control-planeschema is created andmigrated lazily, on thefirst connection
Starting up. What create_app checks before it hands back a WSGI app, and what it refuses.

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.

IF a service token on /api/v1ELSE anything elseBrowsernginxdpf/web/__init__.pybefore_requestdpf/web/gate.py, dpf/web/entra.py, dpf/web/accounts.pyThe doorgate, entra or theaccountdpf/web/views.pyThe viewdpf/db.pyControlplaneSQLiteGET a pageproxied, withthe prefix keptmachine.admitted()may this requestthrough at all?admitted, or aredirectopen the connection; settings into the environmentwhich registeredaccount?a principal, ornobodythe request, with g.principal and g.userreads; a writeappends to theaudit logthe pagecommits on a clean exit, rolls back on an exception
A page, end to end. The hooks in create_app, in the order they actually run.

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.

IF the same principal, or the same name, asked thisELSE a different person answersSourceownerdpf/web/views.pydecide_demanddpf/domain/rbac.pyThe permissionsdpf/repo.py, dpf/audit.pyThe recordsends the decisionmay this role decide?allowed, or refuseddoes it own this system?allowed, or refusedthe same hand,under anothername?refused; nothing iswrittenrepo.decide_demand()updates the row,then appends tothe audit logdecided, on the record
A decision at a gate. decide_demand, step by step — two permission checks, two four-eyes checks, then the record.

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.

IF accepted, as drafted or editedELSE written by hand insteadUsecaseteamdpf/web/views.pyrun_stepdpf/agents.pyThe agentstepdpf/llm.pyllm.rundpf/llm.pyModelproviderAnthropic'sAPIdpf/repo.py, dpf/audit.pyThe recordruns this stepdraft_requirements()llm.run()the systemprompt and thegroundingstructuredoutputlogs the call before it returnstokens, costand the parseddraftthe proposala new proposal, awaiting a decisionshows it forreviewaccepts thechosen onessaves arequirementdirectlyboth paths call pipeline.import_requirements
An agent drafts, a person decides. run_step, for the requirements agent — what it sends, what it logs, and the two ways a person finishes it.

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.

IF every one holdsELSE the gate refusesDataengineerdpf/pipeline.pypipelinedpf/warehouse.pyWarehousedpf/domain/release.pyThe releasebuild()generates the SQL,deterministicallymaterialises; runs everyexpectationrows, and eachexpectation's resulta new release, validated or blockedapproves itpromotes itapproved? granted? signed?materialises the stored SQLinto prodlive; supersedes the old; publishes the contract, as 1.0.0 thefirst timewhy, and nothing iswrittena rollback promotes an earlier release again, replaying its stored SQL
Build, release, promote, roll back. pipeline.build, cut_release, approve and promote, and what rollback actually calls.

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.

IF live and matchingELSE missing, wrong or revokedIF a plain GETELSE a JSON-RPC callA systemor agentdpf/web/machine.pyThe token doordpf/web/api_v1.py/api/v1dpf/mcp.pyThe ontologydpf/db.pyControlplanepresents a bearertokenhashes it; looks up the tokenthe row, or nothingadmitted; standsaside401, and how to getonereads a resourceposts to /mcphands off, borrowingthe connectionreads only; no route writesJSON
A system reads the record. The token door, then a plain read or a JSON-RPC call to the same tools.

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.

IF not open now, new or returnedELSE open before, and gone nowDataengineerdpf/web/views.pyFlags pagedpf/pipeline.pydetect_driftdpf/domain/flags.pyThe detectorsdpf/repo.pyThe boardasks to detectdriftdetect_drift()gathers hashes,coverage and thelast rundetect()a flag for everycondition foundfolds it into the boardraises it, orreopens itresolves itaccepts one instead, in writingneeds areason; neversilent
Drift. detect_drift folds a fresh pass into what is already open, then a person may accept one.

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".

Documentation for version 0.2.0 Reviewed 2026-10-19 Covers dpf/web/__init__.py, dpf/domain/flow.py, dpf/web/gate.py, dpf/web/machine.py, dpf/web/accounts.py, dpf/web/views.py, dpf/domain/rbac.py, dpf/audit.py, dpf/agents.py, dpf/privacy.py, dpf/llm.py, dpf/pipeline.py, dpf/domain/release.py, dpf/warehouse.py, dpf/web/api_v1.py, dpf/mcp.py, dpf/domain/flags.py