Tighten data model lifecycle invariants

This commit is contained in:
JustinZeus 2026-07-23 00:08:14 +02:00
parent ea7b51d56b
commit e36e486490

View file

@ -155,6 +155,7 @@ This is the single per-user state row for a global publication.
| `id`, `public_id` | Stable library item identity. | | `id`, `public_id` | Stable library item identity. |
| `user_id`, `publication_id` | Required and globally unique as a pair. | | `user_id`, `publication_id` | Required and globally unique as a pair. |
| `first_seen_at` | Earliest time any follow path delivered this publication to the user. | | `first_seen_at` | Earliest time any follow path delivered this publication to the user. |
| `first_discovery_kind` | Discovery kind of the earliest origin; does not change when later paths arrive. |
| `read_at` | Null means unread; non-null means read. | | `read_at` | Null means unread; non-null means read. |
| `favorited_at` | Retains legacy favorite state. No v1 UI is implied by this column. | | `favorited_at` | Retains legacy favorite state. No v1 UI is implied by this column. |
| `created_at`, `updated_at`, `row_version` | Shared storage conventions. | | `created_at`, `updated_at`, `row_version` | Shared storage conventions. |
@ -168,6 +169,7 @@ This table preserves why a user can see a publication and makes unfollow, merge,
| Column | Contract | | Column | Contract |
|---|---| |---|---|
| `id`, `public_id` | Stable origin identity. |
| `user_publication_id` | Required parent library item. | | `user_publication_id` | Required parent library item. |
| `follow_id` | The user's follow that supplied the path. | | `follow_id` | The user's follow that supplied the path. |
| `author_publication_id` | The canonical authorship path. | | `author_publication_id` | The canonical authorship path. |
@ -187,8 +189,9 @@ restore prior state without reconstructing history.
- a refollow restores that state; - a refollow restores that state;
- `favorited_at` is migrated and retained even though the frozen v1 UI has no favorite control; - `favorited_at` is migrated and retained even though the frozen v1 UI has no favorite control;
- a legacy publication is read if any legacy link for that user says read; - a legacy publication is read if any legacy link for that user says read;
- `NEW` is derived only from an `incremental_sync` origin and a configurable age window. Baseline, - `NEW` is derived only when `first_discovery_kind = 'incremental_sync'` and `first_seen_at` is in a
manual, and legacy imports never appear as new. configurable age window. A later incremental path cannot make an already-known publication new.
Baseline, manual, and legacy imports never appear as new.
## Global author identity ## Global author identity
@ -212,6 +215,8 @@ Constraints and application invariants:
- `merged_into_author_id` is null exactly when `status = 'active'`. - `merged_into_author_id` is null exactly when `status = 'active'`.
- An author cannot merge into itself, and merge chains must be acyclic. - An author cannot merge into itself, and merge chains must be acyclic.
- A later merge retargets every existing alias to the final active winner in the same transaction,
so stored aliases remain flat rather than forming chains.
- Reads resolve a merged ID to its active target, but APIs preserve the old public ID as a stable - Reads resolve a merged ID to its active target, but APIs preserve the old public ID as a stable
alias so imported links and audit records do not break. alias so imported links and audit records do not break.
- A shell is a valid active author. It may contain only an inert Scholar import identity and no - A shell is a valid active author. It may contain only an inert Scholar import identity and no
@ -253,11 +258,13 @@ Provider labels, aliases, and transliterations are preserved without gaining ide
| Column | Contract | | Column | Contract |
|---|---| |---|---|
| `id`, `public_id` | Stable label identity. |
| `author_id` | Required canonical author. | | `author_id` | Required canonical author. |
| `name`, `normalized_name` | Display/search forms. Neither is unique. | | `name`, `normalized_name` | Display/search forms. Neither is unique. |
| `kind` | `preferred`, `alias`, or `transliteration`. | | `kind` | `preferred`, `alias`, or `transliteration`. |
| `locale` | Optional BCP 47 language tag. | | `locale` | Optional BCP 47 language tag. |
| `source_identity_id` | Optional provenance pointer. | | `source_identity_id` | Optional provenance pointer. |
| `status` | `active` or `retired`. |
| `created_at` | Immutable creation time. | | `created_at` | Immutable creation time. |
At most one active preferred name exists per author. Changing the preferred name does not change At most one active preferred name exists per author. Changing the preferred name does not change
@ -319,7 +326,7 @@ non-secret dump fingerprint and legacy row ID so repeated dry runs remain idempo
|---|---| |---|---|
| `id`, `public_id` | Stable identifier row. | | `id`, `public_id` | Stable identifier row. |
| `publication_id` | Current canonical publication. | | `publication_id` | Current canonical publication. |
| `kind` | Initially `doi`, `arxiv`, `openalex`, `pmid`, `pmcid`, or `other`. | | `kind` | Registered identifier namespace, initially `doi`, `arxiv`, `openalex`, `pmid`, or `pmcid`. |
| `value_raw`, `value_normalized` | Display and equality forms. | | `value_raw`, `value_normalized` | Display and equality forms. |
| `normalization_version` | Parser version. | | `normalization_version` | Parser version. |
| `first_source_record_id` | Provenance for the first accepted assertion. | | `first_source_record_id` | Provenance for the first accepted assertion. |
@ -335,12 +342,13 @@ the same transaction or stop for review. It may not insert a duplicate.
`author_publications` is the canonical many-to-many relationship. Its pair `author_publications` is the canonical many-to-many relationship. Its pair
`(author_id, publication_id)` is unique for all lifecycle states. It carries `status` (`active` or `(author_id, publication_id)` is unique for all lifecycle states. It carries `status` (`active` or
`retired`), `first_observed_at`, `last_observed_at`, and the shared audit fields. `retired`), stable internal and public IDs, `first_observed_at`, `last_observed_at`, and the shared
audit fields.
`author_publication_evidence` records why the relationship exists. It references one `author_publication_evidence` records why the relationship exists. It references one
`author_publication`, an optional `author_source_identity`, and one `publication_source_record`. `author_publication`, an optional `author_source_identity`, and one `publication_source_record`.
The tuple of those three references is unique. Removing or correcting one provider assertion does It has stable internal and public IDs. The tuple of those three references is unique. Removing or
not erase other evidence for the same authorship link. correcting one provider assertion does not erase other evidence for the same authorship link.
## Duplicate prevention and merge rules ## Duplicate prevention and merge rules
@ -371,6 +379,7 @@ not erase other evidence for the same authorship link.
6. A merge reassigns source records, identifiers, authorship links, library rows, and origins in 6. A merge reassigns source records, identifiers, authorship links, library rows, and origins in
one transaction. Duplicate links are coalesced without losing the per-user read/favorite state one transaction. Duplicate links are coalesced without losing the per-user read/favorite state
or provenance. The losing publication remains as `merged` with a stable alias. or provenance. The losing publication remains as `merged` with a stable alias.
7. A later merge retargets all earlier aliases to the final active winner in the same transaction.
The legacy Scholar cluster ID may be retained as `legacy_import` evidence, but it is not a new The legacy Scholar cluster ID may be retained as `legacy_import` evidence, but it is not a new
root identifier and never outranks a sanctioned provider identifier. root identifier and never outranks a sanctioned provider identifier.
@ -405,13 +414,15 @@ There is at most one non-superseded row per `dedupe_key`. A repeated failure upd
### `review_candidates` and `review_decisions` ### `review_candidates` and `review_decisions`
`review_candidates` stores the stable candidate order, referenced existing author or source `review_candidates` has stable internal and public IDs and stores the stable candidate order. A
identity, and a versioned evidence summary used by the UI. It never creates an identity attachment candidate may reference an existing author or identity, or carry a proposed source plus normalized
before the user decides. external ID that does not become an `author_source_identities` row until acceptance. A versioned
evidence summary supplies the UI. No candidate creates an identity attachment before the user
decides.
`review_decisions` is append-only. It records the item, action, actor, selected candidate or target, `review_decisions` has stable internal and public IDs and is append-only. It records the item,
evidence version, operation ID, creation time, and optional `undone_at`. A later decision never action, actor, selected candidate or target, evidence version, operation ID, creation time, and
overwrites an earlier one. optional `undone_at`. A later decision never overwrites an earlier one.
### Negative identity evidence ### Negative identity evidence
@ -567,6 +578,8 @@ tests exist:
13. Each schema migration passes up, down, foreign-key, integrity, and interrupted-upgrade tests. 13. Each schema migration passes up, down, foreign-key, integrity, and interrupted-upgrade tests.
14. Property tests prove normalization idempotence, merge outcome independence from argument order, 14. Property tests prove normalization idempotence, merge outcome independence from argument order,
and stable public-ID alias resolution. and stable public-ID alias resolution.
15. Merging an earlier winner into a third entity flattens every author or publication alias to the
final active winner; undo restores the prior flat mapping.
## Owner decisions ## Owner decisions