Skip to content

feat(legal-status): evidence-carrying US and EP legal-status assessment (ADR 0005 stage 2) - #11

Merged
corlin merged 1 commit into
mainfrom
feat/legal-status-us-ep
Oct 4, 2026
Merged

corlin merged 1 commit into
mainfrom
feat/legal-status-us-ep

Conversation

@corlin

@corlin corlin commented Oct 4, 2026

Copy link
Copy Markdown
Owner

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, expired or undetermined. Every result carries:

  • the events it relied on
  • the term arithmetic (start date, basis, adjustment, expiry)
  • the source and the source's data snapshot time
  • review reasons

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 clients
  • fixtures/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 limitations

Every 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)

  • Lookup is by patent number: Google Patents' US application_number doesn't match the USPTO application number. That was measured; the first lookup returned 404.
  • Term start is the earliest of the patent's own filing date and its CON/DIV/CIP/NST parents. 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.
  • Lapse is the "Expired Due to NonPayment of Maintenance Fees" status, corroborated by the EXP. event (they agreed 16/16). A lapse recorded after the analysis date isn't applied.
  • 4 sampled patents had passed their 20-year 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 treat them as live.

EP (EPO OPS INPADOC)

  • Per-state lapse, reinstatement (PGRI) and renewal-fee year are parsed from ops:pre (Ref Country Code, Effective DATE, Year of Fee Payment) and from national codes prefixed XX:. Only events effective by the analysis date count.
  • lapsed requires every grant-time designated state (from the AK event of the B document) to have a lapse in effect.
    • States with no reported events are flagged, not assumed live or dead.
    • Unitary effect isn't inferred as lapsed from national events.
    • An unknown negative EP-level event makes the result undetermined.

Uncertainty keeps a patent in the FTO risk set (presumed_in_force or undetermined, with a reason), never lapsed/expired. In FTO, wrongly treating a live patent as dead is the costly error.

Sample results (as of 2026-10-04, 117 patents)

US (60) EP (57)
presumed_in_force 40 34
lapsed 16 23
expired 4 0
undetermined 0 0

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 QuotaExceeded error. "Not found" returns None, which callers must treat as unknown.

Reviewer notes / needs your approval

  • Fixtures: fixtures/README.md requires "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.
  • Data freshness: ODP lastIngestionDateTime varies per record and was months old in the sample (e.g. 2026-04-29). Recent lapses may not show yet. Each assessment records it.
  • Not handled yet (flagged only): terminal-disclaimer expiry, unitary-patent lapse, invalidation by courts or offices (outside these sources).
  • Adapter location: adapters/uspto/ and adapters/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 new adapters/legal_status/ package.
  • Not yet wired into the API, DB or worker. Where to store public legal-status data (it's shared across tenants, so not under per-org RLS) is a design decision for the next step.

Validation

  • 34 new tests. Mutating PRO/NST handling or reinstatement makes the targeted tests fail.
  • A live smoke test against both APIs passed (2 US, 2 EP, plus a missing patent returning None).
  • .venv/bin/pytest apps/api/tests/unit/: 373 passed
  • ./scripts/test-postgres.sh: 84 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

…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
corlin merged commit d040053 into main Oct 4, 2026
1 check passed
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)
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)
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