Workflow access lifecycle adapter
© Pi Soft, 2018-2026 · Tricryption Engine 8.1
A Python library a trusted application backend embeds to tie protected top-level JSON fields,
and temporary access to them, to a step of a workflow -- and to end that access when the step
closes. It runs on the starter client (examples/python-agent-starter/client.py), imports the Python standard library only
(sqlite3 included), and keeps every access decision in the key server, reached through the
REST Gateway's existing /api/v1 routes. There is no workflow policy in the Gateway and no new
Gateway route.
Set the starter up first (the starter's README: bootstrap/bootstrap.py and config.json). Everything
here uses that same config.json.
A TypeScript backend embeds the same adapter -- the same operations, refusals and journal file -- from the
TypeScript integration starter (workflow/adapter.ts).
The seven operations
from adapter import WorkflowAdapter, WorkflowError
from client import Config
wf = WorkflowAdapter(Config.load("config.json"), "policy.json", "workflow.sqlite")
w = wf.begin_workflow(time.time() + 3600) # -> workflow id
doc_id, frags = wf.protect_fields(w, claim, ["field_a", "field_b"]) # -> {field: fragment id}
wf.grant_step(w, "review", "a1", "claims-review", window_s=300) # -> the deadline (UTC seconds)
data = wf.decrypt_fragment(w, "review", "a1", frags["field_a"]) # -> plaintext bytes
wf.close_step(w, "review") # -> "closed"
wf.close_workflow(w) # -> "closed"
wf.status(w) # steps, deadlines, pending_cleanup
| operation | what it does on the key server |
|---|---|
begin_workflow(deadline_utc) | creates the workflow's owner principal, wf.<workflow>.owner (your service's XAuth namespace) |
protect_fields(workflow, doc, fields) | encrypts each listed top-level field under its own key, as the owner; the journal stores the fragment id, the key handle (T-tag), the ciphertext and its digest -- never the plaintext |
grant_step(workflow, step, actor, purpose, window_s) | creates (or finds) the principal wf.<workflow>.<step>.<actor> and gives it one READ entry per field the policy allows, with maxUsage: -1 and an endTime of min(now + window, workflow deadline); the step is active only after every entry reads back from acl/get. A second call for the same step and actor moves the deadline (acl/update) |
decrypt_fragment(workflow, step, actor, fragment) | decrypts on the step/actor's own delegated session, held for the step's life; the key server decides |
close_step(workflow, step) | marks the step closing (no new grant or decrypt is let through), revokes each entry and reads it back absent, closes the step's sessions, marks it closed. A second call is a no-op |
close_workflow(workflow) | closes every step, expires the owner's keys -- each key's record is kept as the workflow's evidence and every later use of it, by anyone, is refused 410 KEY_EXPIRED; a fragment counts as done only on its own result, read back expired, and a refused one leaves the workflow closing with that fragment pending -- then removes the workflow's principals. (To delete a key outright, client.remove_keys.) |
status(workflow) | steps, states, effective deadlines, and pending_cleanup |
Window: 300 s by default, 3600 s at most (WF_WINDOW_TOO_LONG). A grant is refused
WF_TIME_UNHEALTHY when the Gateway's clock -- its Date header on GET /api/v1/ready -- is
further from this host's than workflow.max_skew_seconds (2), or cannot be read; and
WF_WINDOW_TOO_SHORT when the effective window (less the measured skew) does not clear that bound
by workflow.min_window_margin_seconds (2). These keys, and the retrier's below, are optional,
under "workflow" in config.json.
A use count is not a budget (the key server decrements it with an unlocked read-modify-write), so every grant is unlimited uses inside a time window the key server enforces on every use.
The policy file
policy.example.json is {purpose: {step: {actor: [fields]}}}:
{ "claims-review": { "review": { "a1": ["field_a"], "a2": ["field_b"] } } }
An unknown purpose, step or actor is refused WF_POLICY_DENIED before anything is sent, and so
is a decrypt of a field the actor's grant does not cover. That refusal is the adapter's; the key
server would refuse the same request with 403 ACL_DENIED, because no entry was ever made.
The plaintext boundary
The backend holds the service certificate and the journal. An agent -- whatever performs the
step -- is handed only the bytes decrypt_fragment returned for the one field it was allowed: never
a credential, a session, a T-tag, an exported key or the rest of the document. A T-tag a caller
passes to decrypt_fragment(..., ttag=...) must equal the journal's (WF_METADATA_MISMATCH); it
is never used. A fragment id from another workflow is WF_UNKNOWN_FRAGMENT.
The journal, and what "pending cleanup" means
One SQLite file in WAL mode, owned by one process. Every write to the key server is preceded by a
committed ops row in state pending and followed by applied, verified or abandoned; the
database transaction is never held across a network call, and a step's grant and close are
serialized. The journal holds ids, key handles, ciphertext and states -- no bearer, DPoP proof,
private key, password or plaintext.
Step states: granting -> active -> closing -> closed, or blocked (a grant failed part way
or landed on a step that never became active) or abandoned (a crash before anything was sent).
status(...)["pending_cleanup"] lists every op not yet verified and every entry that still
stands on the key server on a step that is closing, blocked or past its deadline. An entry past its
deadline is already refused by the key server; it is still listed, because only a revocation read
back absent removes it. A timeout is never reported as done.
The export outbox
Beside the journal the adapter keeps a second SQLite file, security-events.db (export_outbox.py,
standard library only): an append-only record of the security-relevant outcomes, in the
version-1 envelope of integrations/security-events/schema/security-event.schema.json. Each row is
written and committed before the outcome is returned to your code and before the journal
records it:
| event | when |
|---|---|
workflow.grant (succeeded) and workflow.expiry_scheduled | the key server applied a grant; scheduled_for is the endTime sent |
workflow.grant (refused) | the key server refused a grant (reason is its code) |
workflow.refusal | the adapter refused a grant_step or decrypt_fragment itself (reason is the WF_* code) |
workflow.cleanup (succeeded, REVOKED or ALREADY_GONE) | a grant read back absent after its revocation |
workflow.cleanup (failed) | every revocation attempt spent and the entry still standing (retried later) |
workflow.expiry_observed | a step seen past its deadline, or a decrypt the key server refused after endTime -- once per grant |
correlation is exact: the workflow, the step and the adapter process's run_id. The object is
the fragment's opaque id and the recipient principal -- never its T-tag, ciphertext or plaintext.
Pass export_outbox=None to WorkflowAdapter to turn it off, or a path to put it elsewhere.
Drain it with the dispatcher (integrations/security-events/dispatcher/dispatch.mjs --producer workflow), or with the standard library alone:
python3 -B export_outbox.py security-events.db pending
python3 -B export_outbox.py security-events.db drain --to events.jsonl
A drain appends one JSON line per pending event, fsyncs the file, then marks them exported; a
second drain writes nothing. ⚠ The journal's ops table is not the audit trail: it is recovery
state, rewritten as operations settle. Export the outbox, never the journal.
After a crash: what a restart does
Your process can die anywhere. Build a new WorkflowAdapter over the same journal file and it
recovers on its own -- you do not call anything else:
- It logs in again, by certificate. No session, bearer or DPoP proof is ever written down, so there is nothing to restore and nothing to steal from the disk.
- It runs
reconcile()before it returns, and reads the key server -- not the journal -- to decide each case:
| what the journal says | what the key server says | what the restart does | step afterwards |
|---|---|---|---|
a grant's intent pending, nothing sent | no entry for the step's principal | marks the intent abandoned | abandoned: call grant_step again, it starts fresh |
a grant's intent pending | the entry IS there | revokes it, reads it back absent | blocked: a step is never adopted half-granted. Close it |
the step active, its deadline passed | the entry stands (already refused at use) | revokes it as cleanup, reads it back absent | closed, deadline_passed: true |
the step closing (a revocation pending) | anything | retries the revocation | closed once it reads back absent |
| an encrypt sent, no answer recorded | unknown (a key may exist) | marks the field ambiguous | the field is never minted again (WF_AMBIGUOUS_FIELD) |
⚠ A crashed process's Gateway sessions are not closed by the restart -- it never had their
bearers. The Gateway holds them until its idle TTL (derived from the key server's idle window), and
they count against its session cap (sessions.maxSessions, 16 by default): a crashed adapter
leaves about two (its service session and the owner's or a step's). Size the cap for your steady
use plus what a burst of crashes leaves inside one idle TTL.
wf.reconciled is a summary of what it did (steps_abandoned, steps_blocked, steps_expired,
grants_revoked, revocations_pending, ...). A blocked or closing step refuses every decrypt
locally with WF_STEP_CLOSED, and the key server refuses it too (403 ACL_DENIED) once its
entries are gone. Recovery never moves a deadline: no restart sends a grant or an acl/update.
When the key server is unreachable
close_step never waits for the key server. If it cannot revoke an entry (the key server is
down, a network partition, a 502 from the Gateway) it returns "closing" at once, and:
- the step is
closing: no new grant and no decrypt is let through on it (WF_STEP_CLOSED); - each revocation is a journalled op in state
pending, andstatus(w)["pending_cleanup"]names it (kind: "revoke", the step, the attempt count, the last error);status(w)["retrying"]is true while the background retrier is working on it; - a background thread retries after
workflow.cleanup_retry_seconds(1), doubling up toworkflow.cleanup_retry_max_seconds(30), until every entry reads back absent -- then the step isclosedandpending_cleanupis empty.
A step is never reported closed because a call timed out -- only because the key server read
the entry back absent. So closing with pending_cleanup means "access is ended on the adapter's
side and still standing on the key server's"; closed with an empty pending_cleanup means it is
gone. Treat them differently: the first is an alert, the second is done. Other workflows are not
affected by one workflow's pending cleanup. If the process exits while cleanup is pending, the
next adapter's reconcile() takes it up.
The Gateway's clock
Every grant first reads the Gateway's Date header. A skew within
workflow.max_skew_seconds is subtracted from the window -- in whichever direction it lies --
so the access never outlives the window on either clock; status(w)["clock"] holds the last
measurement and each step's deadline is the stamp the key server holds. The Gateway stamps
Date somewhere between the request and the answer, so the skew is known only to within that
round trip (and Date's one second): the clock is unhealthy only when the whole of that interval
lies beyond the bound, and a slow answer from a right clock is not mistaken for skew. A skew beyond
the bound, or no Date header at all, refuses the grant with WF_TIME_UNHEALTHY before anything
is sent.
The time window on the wire
The grant's endTime is not an ISO date: it is the key server's own time_t, sent as base64 of
an 8-byte big-endian count of UTC seconds (stamp() in adapter.py:
base64.b64encode(struct.pack(">Q", int(deadline))), the deadline floored to the second). A
deadline of 1767225600 (2026-01-01T00:00:00Z) is sent as "AAAAAGlVuQA=". maxUsage is -1,
unlimited uses within the window. The key server checks the window on every use, and refuses a
decrypt after endTime with 403 ACL_DENIED. The read-back after a grant compares the
entry's endTime with the same encoding, and requires endTimeUnset false and maxUsage -1.
Crash points
For recovery testing, TE_WF_CRASH_AT makes the process exit with status 70 at exactly one place:
| value | where | what the restart finds |
|---|---|---|
after-intent | a grant's intent row is committed; nothing has been sent | the step abandoned, nothing on the key server |
after-remote-grant | the key server applied the grant; the journal still says pending | the entry revoked, the step blocked |
Run the example
From examples/python-agent-starter/, with the config.json the bootstrap wrote:
python3 workflow/example_workflow.py --config config.json # narrated
python3 workflow/example_workflow.py --config config.json --json # one JSON verdict per step
It protects two fields of one synthetic claim, opens the step review for agents a1 (field_a)
and a2 (field_b), lets each read its own field, shows a1 refused field_b, closes the step,
shows a1 refused again (WF_STEP_CLOSED), and closes the workflow with nothing pending. The
journal defaults to <run_dir>/workflow.sqlite; --journal names another.
--exit-after-grant --window 6 stops after the grants with nothing closed: the key server alone
ends the access at the deadline, and a later adapter's status reports the cleanup pending.
Every behaviour on this page is qualified end to end against a live key server and Gateway before a release: the grants and their read-back, each refusal, the close and its revocations, both crash points and the restart, the unreachable key server and the clock bound.