Skip to content

WIP durable state models#112

Merged
nonprofittechy merged 50 commits into
mainfrom
draft-state-model
Jul 23, 2026
Merged

WIP durable state models#112
nonprofittechy merged 50 commits into
mainfrom
draft-state-model

Conversation

@nonprofittechy

@nonprofittechy nonprofittechy commented Jun 29, 2026

Copy link
Copy Markdown
Member

Summary

Adds a durable filing-draft foundation for the LITEFile workflow while preserving the existing session-backed behavior for incremental migration and review.

Key changes:

  • Introduces durable source-of-truth models:
    • FilingDraft for one filing workflow instance, including jurisdiction, status, current step, case classification, existing-case identifiers, selected payment account, extracted guesses, optional services, extra case data, and submission response.
    • FilingDocument for lead/supporting uploaded documents, including S3/public URL metadata, file metadata, Tyler filing/document/component codes, courtesy copy email, and document order.
    • FilingParty for people or organizations associated with a draft, including role, party type, contact fields, name fields, address fields, and metadata.
  • Adds Django admin support for the new filing draft, document, and party models.
  • Adds request-independent draft service helpers in services/drafts.py.
  • Adds services/current_drafts.py to resolve the current browser's draft while validating authenticated ownership, active status, and jurisdiction. The session stores only the current durable draft ID.
  • Adds services/legacy_draft_bridge.py as a temporary compatibility adapter that mirrors legacy case_data and upload_data session blobs into durable draft/document/party records.
  • Adds a durable draft creation route at POST /jurisdiction/<jurisdiction>/drafts/ and a current-draft API at GET /api/draft/.
  • Updates the options/start flow to create a durable draft and redirect to the first upload step, while falling back to the legacy session start path if draft creation fails.
  • Updates legacy case-data and upload-data save endpoints to shadow-write into the current durable draft so the old UI can continue working during migration.
  • Adds get_workflow_step_choices() so the model's current_step choices stay derived from the workflow registry.
  • Adds documentation in docs/filing-draft-data-model.md describing the model boundaries, compatibility bridge, rollout expectations, and follow-up migration sequence.
  • Adds tests for model/service normalization, document sync, draft snapshots, draft creation, current-draft ownership/jurisdiction protection, legacy endpoint mirroring, workflow-step choices, partial case updates, and complete upload replacement behavior.

Behavior and compatibility notes

  • This PR is intended as a foundation, not a full cutover from session state.
  • Existing session-backed behavior remains in place for incremental review.
  • The durable draft path requires an authenticated user and validates ownership on each current-draft lookup.
  • Jurisdiction is checked when resolving a current draft so a browser/session cannot accidentally resume a draft from another jurisdiction.
  • extra_case_data, document metadata, and party metadata are intentionally kept as temporary escape hatches while the UI moves from arbitrary session blobs to typed draft updates.
  • Legacy session save endpoints continue to save session data, then mirror known fields into the durable models when a current draft exists.

Migration and deploy notes

  • The efile app previously had no migrations even though existing environments may already have the UserProfile table.
  • 0001_initial.py therefore baselines the existing UserProfile model.
  • 0002_filing_drafts.py creates the new FilingDraft, FilingDocument, and FilingParty tables, indexes, and ordering constraints.
  • The Fly release command now runs migrate --noinput --fake-initial so existing deployments can fake the baseline migration while fresh databases still apply both migrations normally.

Intended follow-up migration sequence

  1. Replace legacy case-data endpoints with typed draft updates.
  2. Replace legacy upload endpoints with typed document updates.
  3. Move payment selection into explicit draft fields.
  4. Render review from FilingDraft, FilingDocument, and FilingParty.
  5. Move final submission to a draft-backed submission service.
  6. Remove legacy_draft_bridge.py, arbitrary session-merge APIs, and browser storage persistence.

Open question

@BryceStevenWilley this is still a rough starting point for discussing migration to a clearer data model and draft system. Do we need backwards compatibility with the existing draft/session system, or are we free to fully discard it? If we can discard it, a fair amount of the compatibility bridge and fallback code here can be removed.

@nonprofittechy nonprofittechy changed the title Add durable state models WIP durable state models Jun 29, 2026
@nonprofittechy
nonprofittechy marked this pull request as draft June 29, 2026 22:33
@nonprofittechy
nonprofittechy requested a review from Copilot June 29, 2026 23:09

This comment was marked as off-topic.

@BryceStevenWilley BryceStevenWilley self-assigned this Jul 8, 2026
@BryceStevenWilley
BryceStevenWilley self-requested a review July 8, 2026 15:21

Copy link
Copy Markdown
Member Author

I looked into the current CI failure. The failing check is the tests job in CI run 386 on head commit 0b90257698436cbe28d769d8f57e06f4756d7e30.

Root cause: TestExpertFormIntegration.test_expert_form_page_loads only force-logs in a Django user, but this PR now requires a Tyler token before /jurisdiction/illinois/expert_form/ will render. Without request.session["auth_tokens"], get_tyler_token(request, "illinois") returns falsey and the view redirects to /jurisdiction/illinois/login/, so the test gets 302 instead of 200.

Suggested minimal test fix:

diff --git a/efile_app/efile/tests/tests.py b/efile_app/efile/tests/tests.py
index 59d68dc..xxxxxxx 100644
--- a/efile_app/efile/tests/tests.py
+++ b/efile_app/efile/tests/tests.py
@@ -389,6 +389,9 @@ class TestExpertFormIntegration:
         """Create an authenticated client."""
         user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123")
         client.force_login(user)
+        session = client.session
+        session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "token"}
+        session.save()
         return client
 
     def test_expert_form_page_loads(self, authenticated_client):

This matches the durable-draft tests' _authorize_jurisdiction_session() setup and preserves the new token-gated behavior.

@BryceStevenWilley BryceStevenWilley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Generally looks good, in the right direction, but a few notes:

  • don't need to maintain a "legacy" draft thing at all: can wipe the database when this gets merged so we aren't maintaining 2 different formats
  • admin.py isn't really gonna be used, can probably get rid of it
  • haven't looked super closely, but can probably drop is_logged_in from most of the responses, if redirects are already happening on the server side okay

nonprofittechy and others added 9 commits July 21, 2026 18:30
Replace the session-blob store with the FilingDraft aggregate. The
legacy_draft_bridge that mirrored session blobs into the model is gone,
along with the two-formats problem it created: case_data_utils now
(de)serializes the model on every read/write, keeping its request-based
signatures so views, templates, the submission path, and the browser
wire format are unchanged.

The model is fully typed -- extra_case_data and the document/party
metadata catch-alls are removed. services/drafts.py holds an explicit
wire<->model map that is the entire contract; unknown keys are not
persisted, so adding a field a screen needs means adding a typed column
and a map entry. Parties are typed FilingParty rows (petitioner,
new_name, respondent, other) matching the canonical client-side model.

Also cut over every live writer to the model, require auth to persist a
draft, finalize the draft on submit via mark_submitted, and drop the
dead server-side submission path (transform_case_data_to_filing_payload)
plus the unused admin.py and the now-outdated design doc.

Migration 0002 is amended in place (unmerged; DB wiped on merge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The localStorage cache (24h TTL, keyed only by URL) was mirroring
per-user and per-draft state: case/upload data, profile, payment
accounts, the Tyler auth token, and fee quotes. Now that the model is
the source of truth, those stale copies could survive a submit/reset or
leak on a shared browser -- and a cached auth token is its own hazard.

Route all of them through the non-caching request path so they always
hit the server. The cache is now reserved for static, shareable
reference data (dropdowns, form config), which is what it's good for.
Remove the now-dead cachedPost helper.

Add js-tests/api-utils.test.js (node --test, no new deps; kept out of
./tests so Playwright ignores it) to lock in the rule: reference GETs
cache, draft/profile/payment reads do not. Run with `npm run test:unit`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The remote added 13 commits (jurisdiction-token gating + step tracking
on the flow views, and a submission.py wrapper that mirrors the filing
result to the draft). My local work replaced the session-blob store with
the durable model. Reconciliation:

- Kept the remote's view gating + step tracking as-is (disjoint files).
- Kept the remote submission.py wrapper, which adds mark_error on
  failure, and removed the now-duplicate mark_submitted/clear_current_draft
  from session_api.submit_final_filing so the wrapper owns draft status.
- Rewrote test_durable_drafts.py to the union: the typed serialization
  tests, plus the remote's token-gate and submission tests ported to the
  model (their submission setup wrote session case/upload blobs, which the
  model-based submit no longer reads, so the draft is populated instead).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two review findings:

- Existing-case lookup dropped classification codes. The lookup posts
  case_category_code / case_type_code (and court_code), but the serializer
  only accepted the bare case_category / case_type forms, silently losing
  the codes so a resumed existing case had no classification. Accept both
  the bare and *_code wire forms.

- Submission was not idempotent. The wrapper forwarded every request and
  only marked the draft submitted afterward, so concurrent double-clicks
  could each fire the external filing API. Claim the draft into SUBMITTING
  with a single-winner atomic update before forwarding; a losing request
  gets 409 and never calls the API. A precondition failure releases the
  claim back to DRAFT so the user can retry. The current-draft pointer
  lookup now resolves SUBMITTING drafts (a draft mid-submission is still
  the user's current draft) while resume/listings still exclude them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three review findings:

- A submission could stick permanently in SUBMITTING if a request died
  after claiming but before mark_submitted/mark_error. The claim now also
  takes over a SUBMITTING draft whose claim is older than
  SUBMISSION_CLAIM_TIMEOUT (15m), so a crashed submit is recoverable.

- Ambiguous post-external-call failures no longer reset the draft to
  DRAFT. Only failures the submit view raises *before* calling the filing
  API (missing data, missing efile_data, etc.) release the claim; anything
  else is recorded as ERROR so a blind retry can't silently double-file.

- Draft accessors now enforce the route/payload jurisdiction. get_case_data
  / get_upload_data / update_* (and the derived getters) take an optional
  jurisdiction, and the flow views and jurisdiction-aware API writers pass
  it, so a request for jurisdiction A can no longer read or write a draft
  pointed to from jurisdiction B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@BryceStevenWilley BryceStevenWilley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

some small concerns, but we can start addressing them after this PR

Comment on lines +40 to +44
def get_case_data(request, jurisdiction=None):
"""Return the current draft serialized to the case_data blob (``{}`` if none).

Passing ``jurisdiction`` (the route the caller is serving) enforces isolation:
a draft pointed to from a different jurisdiction is not returned.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

jurisdiction is another of the poorly designed parts of the litefile that i tried to fix but didn't really do well.

Given how users are currently designed, they are tightly associated with a specific jurisdiction, so IMO it doesn't make much sense trying to support multiple drafts for the same user in different jurisdiction.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

One of the problems I ran into hit a bug with this - part of the stack assumed I was in the nonexistent "default" jurisdiction and saved a draft there. I think the boundary here makes sense.

Comment thread efile_app/efile/views/submission.py Outdated
Comment on lines +22 to +32
# Errors the session submit view returns *before* it calls the external filing
# API. Only these -- and a confirmed API rejection -- are safe to release back to
# DRAFT; any other failure may have left a filing submitted.
_PRE_SUBMIT_ERROR_PREFIXES = (
"Submission confirmation is required",
"No case data found",
"No upload data found",
"No efile data provided",
"Missing required fields in efile_data",
"Court ID is required",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reading through this file, not entirely sure what the main goal is, but IMO it would be nice for these strings to just be error codes instead of the specific string returned.

FWIW, the type of error code handling that plocket was recommending for the proxy server is a much better fit for LITEfile.

is_logged_in = False
context = {
"is_logged_in": is_logged_in,
"is_logged_in": True,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just to make this clear, it looks like is_logged_in should just be excluded entirely from the context, and the templates should be written to assume that the user is logged in.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I looked into this--we're just using an existing pattern so I think would be a bigger refactor to the downstream template. Fine rolling into this PR but it's already quite big.

@nonprofittechy
nonprofittechy marked this pull request as ready for review July 22, 2026 20:35
@nonprofittechy
nonprofittechy merged commit f8cc222 into main Jul 23, 2026
4 checks passed
@nonprofittechy
nonprofittechy deleted the draft-state-model branch July 23, 2026 14:48
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.

3 participants