Repository navigation
feat(legal-status): evidence-carrying US and EP legal-status assessment (ADR 0005 stage 2) - #11
Merged
Merged
Conversation
…nt (ADR 0005 stage 2) Determines whether a US or EP patent is presumed in force, lapsed, expired or undetermined as of an analysis date, with the events, term arithmetic, source and data snapshot time attached, so the result can be sealed and re-checked. Each rule comes from measured source behaviour (117 hash-sampled H01M grants) plus statute, flagged for counsel review. US (USPTO ODP, looked up by patent number because Google's US application_number does not match the USPTO application number): - term start = earliest of own filing and CON/DIV/CIP/NST parents; PRO and foreign priority excluded (35 USC 154(a)(2)/(3)); + PTA (154(b)). PTA was present on 48/59 sampled patents, up to 1,220 days. - lapse = "Expired Due to NonPayment of Maintenance Fees" status, corroborated by EXP. (16/16 agreed). Lapses recorded after the analysis date are not applied. - 4 sampled patents had passed their term (a DIV of a 2003 parent; three national stages of 2001-2004 PCTs) while ODP still says "Patented Case". A status-only check would call them live. EP (EPO OPS INPADOC): - per-state lapse / reinstatement (PGRI) / renewal-fee year parsed from ops:pre (Ref Country Code, Effective DATE, Year of Fee Payment) and "XX:"-prefixed national codes; only events effective by the analysis date count. - "lapsed" only when every grant-time designated state (AK of the B doc) has a lapse in effect. States with no reports are flagged, not assumed. Unitary effect is not inferred lapsed from national events. Unknown negative EP-level events make the result undetermined. Uncertainty resolves toward keeping a patent in the FTO risk set (presumed in force or undetermined, with review reasons), never toward "dead". Clients (adapters/legal_status) never fall back to fixtures: failures raise, quota is a distinct error, and "not found" is None (unknown). Fixtures are trimmed public ODP/OPS records with personal-data events dropped; the trimmed fixtures give the same results as the raw responses. 34 tests; mutating PRO/NST handling or reinstatement makes the targeted tests fail. A live smoke test against both APIs passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
corlin
added a commit
that referenced
this pull request
Oct 4, 2026
…(ADR 0006) (#13) > Stacked on #11. Merge #11 first; this diff is storage-only. ## Design (ADR 0006) Legal status of a published patent is **public and identical for every organization**. It's stored once, in two **global** tables with no `organization_id` and no tenant RLS. This follows the repo's existing global-table pattern (`global_identities`), protected by exact grants. The integration suite requires RLS only on tenant tables. **Which organization looks at which patent is itself sensitive** (it reveals product direction and competitors). So these tables may contain no requester, case or user field; a test asserts this. Tenant references will live in tenant-scoped FORCE-RLS tables and bind `assessment_id` plus `content_sha256` at sealing time. | Table | Contents | |---|---| | `legal_status_source_records` | One row per lookup, **including `not_found`**: `request_ref` (no credentials), `retrieved_at`, the source's own data time (ODP `lastIngestionDateTime`), the rule-relevant `extract`, and the SHA-256, size and storage key of the **exact response bytes** | | `legal_status_assessments` | status, term, countries, evidence, review reasons, `rules_version`, `content_sha256`; unique per `(source_record_id, as_of, rules_version)` | | Role | Grants | |---|---| | `patent_evidence_worker` | SELECT, INSERT | | `patent_evidence_app` | SELECT | | `patent_evidence_platform` | none | There are no UPDATE/DELETE grants, so both tables are **append-only**: a new lookup or a new rules version adds rows and never rewrites history. - **Raw responses** are content-addressed in object storage (`public/legal-status/<source>/<sha256>.json`). The DB keeps only the fields the rules read. - **Worker flow** (`LegalStatusRefresher`): fetch → archive exact bytes → record → **assess from the stored extract** (so any stored assessment can be reproduced from the DB) → record. A source failure writes **nothing**. `not_found` records the lookup but no status. ## Needs a decision - **Retention of raw responses.** They contain public personal data (inventors, attorneys). Retention is a PIPL/GDPR question for compliance. Until it's decided, raw responses go to object storage only, under a prefix that can be deleted wholesale. - **Default refresh interval.** Not set yet; it should be measured from a first full run's quota use. - **Not in this PR:** job queue and triggering, tenant reference table, API read endpoints. - **`source_data_as_of` timezone:** ODP gives no UTC offset, so UTC is *assumed* for the column. The verbatim string in `extract` is authoritative. ## Integration test coverage gap found while doing this (pre-existing, not fixed here) `scripts/test-postgres.sh` runs only **5 of the 14** integration files, and CI skips all Postgres tests. When I ran all 14 against Postgres: - **6 business-flow tests** (cases/documents, features, search, comparison, reports, full e2e) are skipped even under the script: they read `PE_MIGRATION_DATABASE_URL`, which the script doesn't export. With it set, **all 6 fail at seeding**: `global_identities.display_name` is NOT NULL, and their seed data omits it. - `test_p0_02_e2e_lifecycle` errors on a non-existent table, `organization_audit_events`. - 3 `test_database_context` tests fail when run after other files: an FK error, i.e. an order or seed dependency. **Correction:** #6 said its lifecycle test covered the upload checks. Those tests never actually ran. I've added only this PR's new file to the script. Repairing the rest should be its own PR, since it may surface real regressions. ## Validation - `./scripts/test-postgres.sh`: **92 passed** (84 plus the 8 new), including the downgrade/upgrade cycle through `0018` - `.venv/bin/pytest apps/api/tests/unit/`: 376 passed - `pnpm test:web`: 62 passed; `pnpm build:web`: ok - `verify-source-lock`, `verify-scaffold`, `verify-p0-02`, `verify-p0-e2e`, `docker compose config`: ok 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This was referenced Oct 4, 2026
corlin
added a commit
that referenced
this pull request
Oct 4, 2026
#17) …(ADR 0006) (#13) > Stacked on #11. Merge #11 first; this diff is storage-only. ## Design (ADR 0006) Legal status of a published patent is **public and identical for every organization**. It's stored once, in two **global** tables with no `organization_id` and no tenant RLS. This follows the repo's existing global-table pattern (`global_identities`), protected by exact grants. The integration suite requires RLS only on tenant tables. **Which organization looks at which patent is itself sensitive** (it reveals product direction and competitors). So these tables may contain no requester, case or user field; a test asserts this. Tenant references will live in tenant-scoped FORCE-RLS tables and bind `assessment_id` plus `content_sha256` at sealing time. | Table | Contents | |---|---| | `legal_status_source_records` | One row per lookup, **including `not_found`**: `request_ref` (no credentials), `retrieved_at`, the source's own data time (ODP `lastIngestionDateTime`), the rule-relevant `extract`, and the SHA-256, size and storage key of the **exact response bytes** | | `legal_status_assessments` | status, term, countries, evidence, review reasons, `rules_version`, `content_sha256`; unique per `(source_record_id, as_of, rules_version)` | | Role | Grants | |---|---| | `patent_evidence_worker` | SELECT, INSERT | | `patent_evidence_app` | SELECT | | `patent_evidence_platform` | none | There are no UPDATE/DELETE grants, so both tables are **append-only**: a new lookup or a new rules version adds rows and never rewrites history. - **Raw responses** are content-addressed in object storage (`public/legal-status/<source>/<sha256>.json`). The DB keeps only the fields the rules read. - **Worker flow** (`LegalStatusRefresher`): fetch → archive exact bytes → record → **assess from the stored extract** (so any stored assessment can be reproduced from the DB) → record. A source failure writes **nothing**. `not_found` records the lookup but no status. ## Needs a decision - **Retention of raw responses.** They contain public personal data (inventors, attorneys). Retention is a PIPL/GDPR question for compliance. Until it's decided, raw responses go to object storage only, under a prefix that can be deleted wholesale. - **Default refresh interval.** Not set yet; it should be measured from a first full run's quota use. - **Not in this PR:** job queue and triggering, tenant reference table, API read endpoints. - **`source_data_as_of` timezone:** ODP gives no UTC offset, so UTC is *assumed* for the column. The verbatim string in `extract` is authoritative. ## Integration test coverage gap found while doing this (pre-existing, not fixed here) `scripts/test-postgres.sh` runs only **5 of the 14** integration files, and CI skips all Postgres tests. When I ran all 14 against Postgres: - **6 business-flow tests** (cases/documents, features, search, comparison, reports, full e2e) are skipped even under the script: they read `PE_MIGRATION_DATABASE_URL`, which the script doesn't export. With it set, **all 6 fail at seeding**: `global_identities.display_name` is NOT NULL, and their seed data omits it. - `test_p0_02_e2e_lifecycle` errors on a non-existent table, `organization_audit_events`. - 3 `test_database_context` tests fail when run after other files: an FK error, i.e. an order or seed dependency. **Correction:** #6 said its lifecycle test covered the upload checks. Those tests never actually ran. I've added only this PR's new file to the script. Repairing the rest should be its own PR, since it may surface real regressions. ## Validation - `./scripts/test-postgres.sh`: **92 passed** (84 plus the 8 new), including the downgrade/upgrade cycle through `0018` - `.venv/bin/pytest apps/api/tests/unit/`: 376 passed - `pnpm test:web`: 62 passed; `pnpm build:web`: ok - `verify-source-lock`, `verify-scaffold`, `verify-p0-02`, `verify-p0-e2e`, `docker compose config`: ok 🤖 Generated with [Claude Code](https://claude.com/claude-code)
corlin
added a commit
that referenced
this pull request
Oct 4, 2026
…5) (#16) ## What Stage 1 of ADR 0005: the candidate set of US/EP patents that the stage-2 legal-status check (#11, #14) runs over. The design is in **ADR 0007**, and every scope parameter there is backed by a measurement. | Decision | Basis (measured 2026-10-04) | |---|---| | US `B1/B2/E1`, EP `B1/B2/B3`, granted, H01M/H02J by **CPC OR IPC** | Reissues (`E1`, 201) replace the original patent. CPC-only filtering misses IPC-only documents (see the spike report) | | **US: filed ≥ as_of − 20y − 5y** | Patent term adjustment is common: 48/59 sampled patents had it, up to **1,220 days**. Stage 2 computes the exact expiry | | **EP: filed ≥ as_of − 20y, no margin** | The EPC has no term adjustment. BigQuery's EP filing date matched EPO's for **57/57** samples | | Cost of the margin | +11% rows (US 140,344 → 156,306; EP 48,173 → 53,452) | ADR 0007 also defines a **calibration rule** for the US margin: after the first full legal-status run, check whether any US patent whose term started 20–25 years ago is still presumed in force. ## Storage (ADR 0006 pattern) - Global, append-only tables `patent_corpus_loads` and `patent_corpus_members`. Worker: SELECT+INSERT; app: SELECT; no UPDATE/DELETE; no tenant columns. - Each load records its scope and cutoffs, the **exact query and its SHA-256**, bytes billed, the source table's last-modified time and the row count, so an FTO analysis can name the corpus it used. - A load is written **atomically** (tested: a failure part-way leaves nothing). The row count is known up front because the tables can't be updated. - Inventors are not loaded. Assignee names are, since they're needed for ownership; some may be individuals' names, which are public bibliographic data. ## Loader `scripts/load-patent-corpus.py` is run by an operator; it's not part of any image. - It dry-runs by default (no cost), and the load query dry-runs at **58.88 GB**. - `--execute` requires `--max-gb`, and the script refuses if the scan exceeds it (both guards tested: exit 2 and exit 1). - The BigQuery client is an optional `ingest` extra. **It's absent from the API image** (verified on the rebuilt image). - Credentials come from the operator's Google Cloud ADC; nothing is stored in the repo. ## Not done yet - **No data has been loaded.** The first full load runs after this is reviewed (≈59 GB scan; this month's usage so far is about 333 GB of the 1 TB free tier). - Legal-status support for US reissues (`RE`-prefixed numbers) isn't there yet; they're flagged. - Next steps: batch legal-status job, then the watch tier (published applications), then margin calibration. ## Validation - `./scripts/test-postgres.sh`: **103 passed** (99 + 4 new), including the downgrade/upgrade cycle through `0019` - `.venv/bin/pytest apps/api/tests/unit/`: 382 passed (6 new) - `pnpm test:web`: 62 passed; `pnpm build:web`: ok - `verify-source-lock`, `verify-scaffold`, `verify-p0-02`, `verify-p0-e2e`, `docker compose config`: ok - Loader dry run against BigQuery: 58.88 GB; guard refusals verified 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Stage 2 of ADR 0005 (proposed in #10): decide, as of an analysis date, whether a US or EP patent is
presumed_in_force,lapsed,expiredorundetermined. Every result carries:That lets the result be sealed into an evidence snapshot and re-checked later.
modules/legal_status/: pure rules (us.py,ep.py,models.py)adapters/legal_status/clients.py: USPTO ODP and EPO OPS INPADOC clientsfixtures/legal-status/: trimmed public ODP/OPS records (provenance in its README)docs/validation/2026-10-04-legal-status-us-ep-sample.md: sample run and limitationsEvery rule comes from measured source behaviour
117 H01M grants, hash-sampled per grant year (60 US, 57 EP), plus statute. The statute citations are flagged for confirmation by qualified counsel.
US (USPTO ODP)
application_numberdoesn't match the USPTO application number. That was measured; the first lookup returned 404.CON/DIV/CIP/NSTparents.PRO(provisional) and foreign priority are excluded (35 U.S.C. §154(a)(2)/(3)). These five codes are exactly the ones ODP returned. Patent term adjustment (§154(b)) is added: present on 48/59 sampled patents, up to 1,220 days.EXP.event (they agreed 16/16). A lapse recorded after the analysis date isn't applied.EP (EPO OPS INPADOC)
PGRI) and renewal-fee year are parsed fromops:pre(Ref Country Code,Effective DATE,Year of Fee Payment) and from national codes prefixedXX:. Only events effective by the analysis date count.lapsedrequires every grant-time designated state (from theAKevent of the B document) to have a lapse in effect.undetermined.Uncertainty keeps a patent in the FTO risk set (
presumed_in_forceorundetermined, with a reason), neverlapsed/expired. In FTO, wrongly treating a live patent as dead is the costly error.Sample results (as of 2026-10-04, 117 patents)
The 4 US expiries and several EP results were checked by hand against the raw records. The sample is small, so these proportions validate the rules but aren't population estimates.
Clients never fabricate a status
Unlike the search adapters (which fall back to sample fixtures), these clients raise on failure. Quota exhaustion is a distinct
QuotaExceedederror. "Not found" returnsNone, which callers must treat as unknown.Reviewer notes / needs your approval
fixtures/README.mdrequires "anonymized and governance-approved" fixtures. These are public patent-office records with no client data, trimmed to the fields the rules read. Inventor and party-data events are dropped. The trimmed fixtures give identical results to the raw responses. Please confirm they're acceptable.lastIngestionDateTimevaries per record and was months old in the sample (e.g. 2026-04-29). Recent lapses may not show yet. Each assessment records it.adapters/uspto/andadapters/epo/READMEs say "reserved for a pinned CLI/sidecar". That comes from the superseded spec, and the existing search adapters already call these APIs over HTTP. This PR follows the HTTP pattern in a newadapters/legal_status/package.Validation
None)..venv/bin/pytest apps/api/tests/unit/: 373 passed./scripts/test-postgres.sh: 84 passedpnpm test:web: 62 passed;pnpm build:web: okverify-source-lock,verify-scaffold,verify-p0-02,verify-p0-e2e,docker compose config: ok🤖 Generated with Claude Code