Skip to content

feat: deterministic idempotent workflow engine - #7

Merged
div0rce merged 6 commits into
mainfrom
feat/m06-workflow-engine
May 29, 2026
Merged

feat: deterministic idempotent workflow engine#7
div0rce merged 6 commits into
mainfrom
feat/m06-workflow-engine

Conversation

@div0rce

@div0rce div0rce commented May 29, 2026

Copy link
Copy Markdown
Owner

Milestone

M6 — Deterministic, idempotent workflow engine

Summary

The differentiator. Adds backend/app/workflow.py with a pure rule layer
(route() is a function of RoutingInputs only — no I/O), a deterministic
SHA-256 idempotency-key recipe gated by a ROUTING_VERSION constant, an
idempotent apply_routing upsert that refuses REJECTED → AUTO_APPROVED
promotion (M7's audit-driven approval is the only legitimate path), a replay()
primitive that recomputes decisions from stored extractions, runtime invariant
checks, and 22 tests covering all four M6 DoD categories. Plus
docs/workflow.md documenting the rules, key recipe, transition policy, replay
protocol, and invariants.

Definition of Done

  • All DoD items from MILESTONES.md met
  • make check passes (ruff + ruff-format + mypy strict + pytest)
  • Tests added/updated for new logic — 22 new M6 tests
  • PROGRESS.md updated — M6 marked complete on branch; M5 row updated to ☑ merged
    with PR feat: guardrails (PII redaction, confidence gating, refusal) #6 link; 4 new decision-log entries
  • No secrets committed; sample data is synthetic
  • Guardrails intact (citation-or-refuse, PII redaction, confidence gating, audit
    logging) — rule layer routes invalid_citationrejected; low confidence →
    needs_review. Audit-event emission for routing arrives in M7.

M6 DoD verification (from MILESTONES.md)

  • Determinism testroute() is a pure function of inputs. Tests pin
    that identical inputs produce equal RoutingDecision instances across runs;
    field_confidence insertion order is irrelevant; the idempotency-key recipe
    depends on extraction_id, schema_name, and routing_version only.
  • Idempotency testapply_routing upserts by deterministic key.
    test_apply_routing_is_idempotent_no_duplicate_rows runs three consecutive
    calls and verifies at the SQL level that exactly one row exists with that
    key. test_apply_routing_updates_status_when_decision_changes verifies a
    status change updates the row in place rather than inserting.
  • Replay testreplay() reads the persisted extractions row and
    runs route() against it. Tests cover the auto-approved happy path
    (route_extraction followed by replay returns the same decision and
    idempotency key), the low-confidence path (needs_review recovered from
    storage alone), and unknown-id KeyError.
  • Invariants enforced and tested
    _check_invariants raises AssertionError on AUTO_APPROVED + low confidence
    and on non-REJECTED + invalid_citation. route() never returns auto_approved
    when any field is low (a parametrized user-visible test pins this).
    apply_routing refuses REJECTED → AUTO_APPROVED with IllegalTransition;
    the policy of allowing REJECTED → NEEDS_REVIEW demotion is documented and
    tested.

Notes / decisions

See PROGRESS.md "Decision log" — four M6 entries:

  • Workflow rules are pure (route(RoutingInputs) → RoutingDecision).
  • Idempotency-key recipe deliberately excludes confidence and guardrail flags
    (they change between calls; including them would break re-run idempotency).
    ROUTING_VERSION = "v1"; bumping is auditable and triggers re-routing.
  • Rule precedence: rejecting flag → rejected; low confidence → needs_review;
    non-rejecting flag → needs_review; otherwise → auto_approved. Rejection is
    terminal at routing time.
  • REJECTED → NEEDS_REVIEW demotion is allowed; REJECTED → AUTO_APPROVED is
    refused with IllegalTransition.

Other deliberate choices:

  • The engine never touches the LLM, never reads chunks, and never writes anywhere
    except workflow_items (in apply_routing).
  • Caller owns the transaction: the engine flushes but never commits. This keeps
    it composable inside an HTTP handler or a worker job.
  • route_extraction(session, extraction_id, guardrail_flags=()) is a small
    convenience that ties replay-style routing and idempotent persistence for
    callers (e.g., the M9 eval harness, a future review-queue worker).

Schema/migration concerns

None. The M1 workflow_items table already has idempotency_key (unique),
extraction_id (FK), status (enum), reason, and timestamps — exactly what
M6 needs. Zero migrations in this PR.

Test coverage

backend/tests/test_workflow.py (22 tests):

  • Determinism (4)
  • Rule precedence (5)
  • Idempotency (3, including a SQL-level row-count assertion)
  • Replay (3)
  • Invariants (3 enforcement + 1 user-visible)
  • Transition policy (2)
  • route_extraction integration (2)

Total project tests: 150 (was 128 in M5).

Reminder

Please squash-merge this PR (Golden Rule #2: one milestone = one squash-merge
commit on main).

div0rce added 5 commits May 28, 2026 22:56
…, replay, invariants

backend/app/workflow.py is the M6 differentiator. Three layers, sharp boundaries:

1. Pure rule layer: route(RoutingInputs) -> RoutingDecision is a function with no
   I/O. Identical inputs always produce identical outputs.
2. Idempotency key recipe: compute_idempotency_key(extraction_id, schema_name,
   routing_version) is a SHA-256 hex digest. ROUTING_VERSION ('v1') lets future
   milestones bump the rule set without colliding with old keys.
3. Persistence: apply_routing(session, *, extraction_id, decision) upserts a
   workflow_items row keyed by the deterministic idempotency key. Re-running with
   the same decision is a no-op. REJECTED -> AUTO_APPROVED is refused with
   IllegalTransition; that promotion requires a human event (M7).
4. Replay: replay(session, *, extraction_id) reads the persisted extraction and
   recomputes the decision via route() with no DB writes. route_extraction()
   ties replay-style routing and idempotent persistence together for callers.

Decision rules (top-down precedence):
1. 'invalid_citation' guardrail flag -> REJECTED ('guardrail_rejected').
2. Any field below confidence_review_threshold -> NEEDS_REVIEW ('low_confidence').
3. Any non-rejecting guardrail flag -> NEEDS_REVIEW ('guardrail_review').
4. Otherwise -> AUTO_APPROVED ('ok').

Invariants checked at decision time (raise AssertionError):
- AUTO_APPROVED with any field below threshold is impossible.
- A rejecting guardrail flag MUST yield REJECTED.

The engine never touches the LLM, never reads chunks, and never writes anywhere
except workflow_items (in apply_routing). Tests in the next commit cover the four
DoD categories: determinism, idempotency, replay, and invariants.
22 tests across the four M6 DoD categories:

Determinism (4):
- route() is a pure function of inputs (a == b == c for equal inputs).
- field_confidence dict insertion order does not change the decision.
- compute_idempotency_key depends only on extraction_id, schema_name, version.
- The key is independent of confidence and guardrail flags (so the same
  extraction always upserts to the same workflow_items row).

Rule precedence (5):
- invalid_citation flag -> REJECTED (guardrail_rejected)
- low confidence -> NEEDS_REVIEW (low_confidence)
- non-rejecting flag -> NEEDS_REVIEW (guardrail_review)
- clean inputs -> AUTO_APPROVED (ok), routing_version captured
- invalid_citation beats low_confidence (rejection is terminal)

Idempotency (3):
- apply_routing inserts a workflow_items row.
- 3x apply_routing of the same decision produces ONE row in the DB
  (verified by SQL count, not just same id).
- A re-run with a different decision updates the same row, never duplicates.

Replay (3):
- replay() returns the same decision route_extraction did against the same data.
- replay handles low-confidence extractions correctly.
- replay raises KeyError for unknown extraction id.

Invariants (3):
- _check_invariants raises on AUTO_APPROVED with low confidence.
- _check_invariants raises on non-REJECTED with a rejecting flag.
- route() never returns AUTO_APPROVED when low confidence (user-visible
  invariant pinning the rule precedence).

Transitions (2):
- REJECTED -> AUTO_APPROVED via apply_routing raises IllegalTransition.
- REJECTED -> NEEDS_REVIEW via apply_routing is allowed (documents policy).

route_extraction integration (2):
- Persists idempotently: same extraction id yields a single workflow_items row.
- Propagates guardrail_flags through to the decision.

Total project tests: 150 (was 128 in M5).
…invariants

Covers why determinism is the M6 contract (audit defensibility, safe re-runs,
eval reproducibility), the four-row rule precedence table, the SHA-256 key recipe
with what is and is not included (and why), apply_routing's transition policy
(REJECTED -> AUTO_APPROVED is refused; demotions allowed), the replay protocol
and its two uses (audit assertion, rule-version migration), the two invariants
enforced at decision time, and the test categories.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c7b460bcb3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread backend/app/workflow.py Outdated
@div0rce
div0rce merged commit 80febd2 into main May 29, 2026
1 check passed
@div0rce
div0rce deleted the feat/m06-workflow-engine branch May 29, 2026 03:18
div0rce added a commit that referenced this pull request May 29, 2026
* docs(progress): record M6 merged (PR #7) and mark M7 in progress

* feat(audit): semantic emitters, HITL review router, state-from-replay primitive

backend/app/audit.py adds the M7 layer on top of the M1 append-only audit_events
table:

- AuditAction string constants for the catalogue (extraction.created,
  workflow.routed, review.approved, review.rejected); strings (not enums) so future
  actions can land without an enum migration.
- emit_extraction_created(session, extraction): records every model-produced
  Extraction with the full payload + per-field confidence + citations + model_name
  in the 'after' JSONB.
- emit_workflow_routed(session, workflow_item, prior_status): records routing
  decisions that actually changed state; idempotent re-routes do NOT emit.
- emit_review_decision(session, workflow_item, prior_status, decision, actor,
  note): records human approve/reject decisions with the human's actor and
  optional note.
- replay_workflow_state(session, workflow_item_id): walks audit events oldest-first
  and reconstructs the final WorkflowStatus from 'after.status' values. M7 keystone.

Wiring:
- extract.extract_document calls emit_extraction_created after persistence.
- workflow.route_extraction captures prior status, calls apply_routing, and emits
  workflow.routed only when the workflow_item was inserted or transitioned.

backend/app/routers/review.py:
- GET /review (limit, offset; returns needs_review queue).
- POST /review/{id}/approve -> AUTO_APPROVED + review.approved audit event.
- POST /review/{id}/reject -> REJECTED + review.rejected audit event.
- 409 on transition from any status other than needs_review (the human path is
  scoped to this transition; M6's IllegalTransition guard remains for re-routes).
- main.py includes the review router.

* test(audit): emission counts, review router, state-from-replay (M7 keystone)

Three groups mapping onto the M7 DoD:

Emission counts (3):
- route_extraction emits exactly one workflow.routed event (before=None on insert).
- An idempotent re-route emits zero additional events (DoD: exactly one event per
  state-changing decision).
- A re-route that flips status emits a second event with before set to the prior
  status.

Review router (6):
- GET /review returns needs_review items.
- POST /review/{id}/approve transitions to AUTO_APPROVED, persists, and emits
  exactly one review.approved audit event whose actor and note match the body.
- POST /review/{id}/reject transitions to REJECTED and emits review.rejected.
- A second approve on an already-decided item is 409.
- 404 for unknown ids; 422 for empty actor.

State from replay (2 + 1):
- A multi-event lifecycle (route insert -> route transition -> reject -> reopen ->
  approve) emits 5 audit events; replay_workflow_state walks them and reproduces
  the current workflow_items.status. **M7 keystone.**
- replay_workflow_state returns None for a workflow item with no events.
- AuditAction catalogue is pinned (extraction.created, workflow.routed,
  review.approved, review.rejected).

Total project tests: 163 (was 150 in M6).

* docs(audit): action catalog, emission rules, replay protocol, HITL transitions

Documents the four-action catalogue (extraction.created, workflow.routed,
review.approved, review.rejected), the before/after JSON shapes, the emission
rules (idempotent re-routes do not double-emit, failures do not persist or emit),
the HITL transition table (only needs_review can be approved/rejected via this
router; supervisor reversals are deferred), the state-from-replay contract and
limits, and the wiring diagram.

* docs(progress): mark M7 complete on branch with DoD verification and decision log

* fix: make audit transitions concurrency-safe
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant