Skip to main content

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
operationwhat 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:

eventwhen
workflow.grant (succeeded) and workflow.expiry_scheduledthe 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.refusalthe 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_observeda 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 sayswhat the key server sayswhat the restart doesstep afterwards
a grant's intent pending, nothing sentno entry for the step's principalmarks the intent abandonedabandoned: call grant_step again, it starts fresh
a grant's intent pendingthe entry IS thererevokes it, reads it back absentblocked: a step is never adopted half-granted. Close it
the step active, its deadline passedthe entry stands (already refused at use)revokes it as cleanup, reads it back absentclosed, deadline_passed: true
the step closing (a revocation pending)anythingretries the revocationclosed once it reads back absent
an encrypt sent, no answer recordedunknown (a key may exist)marks the field ambiguousthe 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, and status(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 to workflow.cleanup_retry_max_seconds (30), until every entry reads back absent -- then the step is closed and pending_cleanup is 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:

valuewherewhat the restart finds
after-intenta grant's intent row is committed; nothing has been sentthe step abandoned, nothing on the key server
after-remote-grantthe key server applied the grant; the journal still says pendingthe 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.