# Proposed ontology changes

A review can conclude that a GO (or UBERON, CL, CHEBI …) term is *itself* wrong,
not merely that an annotation used the wrong term. `proposed_ontology_changes`
is where that goes.

## What it is for, and what it is not

Three things in this repository look adjacent and are not interchangeable:

| You want to say | Use |
|---|---|
| this term should not exist, or should say something different | `proposed_ontology_changes` |
| a term that **does not exist** should be created | `proposed_new_terms` |
| an annotation **record** is wrong (wrong gene, wrong term, wrong evidence code) | `review.source_defects` |

The first two are requests to an ontology; the third is a request to whoever
curated an annotation. The distinction that matters most in practice: obsoleting
a term does **not** repoint the annotations that use it. A review that finds both
problems records both, and they go to different trackers.

`proposed_new_terms` is deliberately left alone — it covers creation, it is used
in several hundred reviews, and a `ProposedOntologyTerm` is a KGCL
`ClassCreation` in disguise that could fold in later if anyone wants it to.

## Where it lives

Three homes, in increasing scope:

- **a gene review** (`genes/*/*/*-ai-review.yaml`), top level — when the change
  was noticed while curating one gene;
- **a module review** (`modules/*.yaml`), top level — subtree-level obsoletions
  are usually noticed while curating a pathway rather than a single gene;
- **a project page's frontmatter** (`projects/**/*.md`) — when the change spans
  several terms and MOD groups and so belongs to no single gene or module. The
  `projects/*_OBSOLETION.md` pages are exactly that case.

A gene or module review is schema-validated, so `linkml-validate` checks the
block's structure. **A project page is markdown and LinkML never sees it**, so
the structure is checked by `validate_change_entries`, which reads the required
fields, the allowed fields and the permissible `change_type` values out of the
schema file rather than restating them. `tests/test_project_frontmatter.py`
applies it to every project page, and `render-projects` reports what it rejects
as page warnings.

### Structural errors exclude; policy violations do not

Two severities, and the distinction is deliberate. A **structural** problem — a
missing required field, an unknown field, a `change_type` outside the enum, a
term field with no `id` — makes the entry unusable: the renderer drops it, the
exporter will not ship it, and the frontmatter test fails. A **policy**
violation is advisory: the entry still renders and still exports, and it shows
up as a page warning and in `check_ontology_change_policy` at WARNING severity,
exactly as on a gene or module review.

Getting this wrong is what the first version of the project-page gate did: it
excluded an otherwise fine entry because its `report_status: FIXED` carried no
tracker link. A missing link is worth flagging and is not grounds for dropping
the record.

## Who wants the change: the distinction everything else hangs off

`origin` is required, and it is the first thing to get right. Two cases that look
identical in every other field carry completely different work:

| | `SELF_PROPOSED` | `UPSTREAM_PROPOSED` / `UPSTREAM_COMPLETED` |
|---|---|---|
| What the record is | an outbound request | us tracking somebody else's plan |
| Next action | file a ticket, chase it | migrate our own affected content |
| Load-bearing field | `justification` (the argument) | `affected_annotations` (the blast radius) |
| Lifecycle slot | `request_status` | `adoption_status` |
| Needs `confidence` | yes, it grades our argument | no — their decision is not ours to grade |
| Takes a `stance` | no, proposing it *is* the position | yes |
| How it goes wrong | they never act | **we never notice it landed** |
| Appears in | `<ONT>-requests.md` | `<ONT>-inbound.md` |

The first version of this schema had no `origin`, and the consequence was not
hypothetical: all 15 records committed under it were reactive, every one was
marked with a `report_status` whose documented meaning is *"the source has
corrected the record"* — i.e. acted on **our** report — and every one linked GO's
own ticket as though we had filed it. The report called them "requests for GO."
Nothing in those 15 rows was a request. `just export-ontology-changes` now
reports **0** open requests and 8 inbound changes to adopt.

`stance` exists so that *disagreeing* with an upstream change is expressible at
all. Two live objections in this repository could not previously be recorded:
the vesicle page's warning that `GO:0099023` is a cellular-component term and
**must not** be reused as the molecular-function replacement, and the
amino-acid page's note that batch membership must be confirmed before any
in-repo edits. Those are `DISAGREE` and `NEEDS_CONFIRMATION`. An objection has to
be argued — `thin_objection` fires on a `DISAGREE` or `PARTIALLY_AGREE` carrying
fewer than 20 words, because asserting disagreement is not raising it.

`ontology_state_verified` records when a curator last *looked up* the term's
state, as distinct from when somebody wrote down a plan. Both backfilled projects
had audits claiming a term was live after it had been retired, so an undated
status is not evidence of anything.

### The failure this is really for

`unmigrated_landed_change` fires when a change has landed and its
`affected_annotations` are still `NOT_STARTED` or `IN_PROGRESS`. That is the
failure that actually happens: the ontology moves, and the repository keeps
citing the retired term. It fires **once** on `AMINO_ACID_ACTIVATION_OBSOLETION`
today, on the `GO:0070143` record, naming three paths: `genes/DANRE/gtpbp3`,
`genes/human/AARS2` and
`modules/mitochondrial_trna_charging_and_initiator_formylation.yaml`. Of those,
the module is what is genuinely outstanding — it still binds the obsolete term in
a `processes:` slot, and its evidence statement now contradicts AARS2's own
review.

It fired three times, over nine files, when the field was first added. Two of
those firings were wrong in the other direction: the affected reviews had already
been migrated and the record still said `NOT_STARTED`. So the check found real
unmigrated content *and* stale bookkeeping, and the count only became honest once
the statuses were audited against the files. Read a falling count as either.

## It is KGCL, wrapped

`change_type` names a class from
[KGCL](https://github.com/INCATools/kgcl) (Knowledge Graph Change Language), and
each permissible value carries that class's URI as its `meaning`. The structured
slots carry the KGCL class's own fields, so a proposal can be emitted as KGCL and
applied with `oaklib apply`. `kgcl_schema` is already an indirect dependency via
`oaklib`, so this costs nothing extra.

What KGCL does *not* model is the argument for a change, or which direction the
change is travelling in. `justification`, `supported_by`, `confidence`, `origin`,
`stance`, `request_status` and `adoption_status` are this repository's additions, and
they are the reason to record a proposal here rather than only in a tracker.
`confidence` does reuse `SourceDefectConfidenceEnum`, because grading our own case
means the same thing in both places. The lifecycle does **not** reuse the
source-defect enum: `request_status` and `adoption_status` are separate enums,
because a request made of an ontology and the migration it creates for us are two
different pieces of state that move independently, and the single `report_status`
this started with could not say which one it meant.

### The DSL is checked by KGCL's own parser

`change_dsl` holds the change in KGCL's controlled English
(`obsolete GO:0099022 with replacement GO:7770062`). It is redundant with the
structured slots **on purpose**, so that a disagreement between the two is
detectable rather than silent. Validation parses it with KGCL's own lark grammar
and reports three things: a statement that does not parse (`dsl_unparseable`),
one that names a different term from the slots (`dsl_disagrees`), and one that
parses as a different KGCL class from the declared `change_type`
(`dsl_wrong_kind`).

Leaning on the real grammar rather than a hand-written pattern matters, because
the grammar is narrower than it looks. **KGCL's DSL has no statement for a
merge, a mapping predicate change, or a mapping removal**, even though it has
classes for the first of those. For those change types leave `change_dsl` unset;
the structured slots and the justification carry the request. `kgcl_dsl()`
renders a statement only when KGCL's parser accepts it, so the exporter never
emits a string that looks like KGCL and is not.

Other grammar details worth knowing before hand-writing a `change_dsl`:

- a definition change is `change definition of X to '…'` — there is no `from` clause;
- there is **no syntax for a non-direct replacement** (`consider`), so a correct
  DSL for `NODE_OBSOLETION_WITH_NO_DIRECT_REPLACEMENT` is a bare
  `obsolete GO:…` even when `has_nondirect_replacement` lists alternatives.

### Direct versus non-direct replacement

This is the distinction to get right, because it decides whether anything can be
fixed mechanically.

- `has_direct_replacement` becomes GO's `term replaced by`. It says the two terms
  denote the same thing, so annotations can be repointed without re-curation.
- `has_nondirect_replacement` becomes `consider`. It is advice; a curator must
  choose per annotation.

A biological process obsoleted in favour of a molecular function is **never** a
direct replacement, however obviously the MF is "the" answer — the five vesicle
tethering BPs all point at `GO:7770062 vesicle membrane tethering activity`
through `consider`, and GO records no `term replaced by` for any of them.

## Example

From `modules/dmel_exocyst.yaml`:

```yaml
proposed_ontology_changes:
- change_type: NODE_OBSOLETION_WITH_NO_DIRECT_REPLACEMENT
  ontology: GO
  about_node:
    id: GO:0090522
    label: vesicle tethering involved in exocytosis
  has_nondirect_replacement:
  - id: GO:7770062
    label: vesicle membrane tethering activity
  change_dsl: obsolete GO:0090522
  justification: >-
    Tethering is a binding activity, not a process … recorded as a non-direct
    replacement because a biological process cannot be replaced outright by a
    molecular function.
  origin: UPSTREAM_COMPLETED
  stance: AGREE
  ontology_state_verified: '2026-10-31'
  adoption_status: NOT_APPLICABLE
  upstream_tracker_issue: https://github.com/geneontology/go-annotation/issues/6375
```

Read the `origin`/`stance`/`adoption_status` trio together: GO has already made this
change, we agree with it, and nothing in this repository cites the obsolete term, so
there is no migration to do. An outbound request looks different — `origin:
SELF_PROPOSED` with a `request_status` and no `adoption_status`, because there is
nothing to adopt until the ontology acts. `confidence` belongs on an outbound request,
where it grades our own case; a landed upstream change needs
`ontology_state_verified` instead, which is a date we checked rather than a judgement.

## Reporting

```bash
just export-ontology-changes
```

Writes `reports/ontology-changes.tsv` plus **two** Markdown reports per ontology
in `reports/ontology-changes/`:

- `<ONT>-requests.md` — the outbound document, holding only `SELF_PROPOSED`
  changes with an open `request_status`. This is what gets sent upstream, so an
  upstream-originated change must not appear in it: that would hand the
  ontology's own decision back to them as our request.
- `<ONT>-inbound.md` — the worklist for curators here, holding changes the
  ontology originated (or ours that it accepted) whose `adoption_status` is not
  yet `COMPLETE` or `NOT_APPLICABLE`. Nothing here is owed upstream; the work is
  ours.

Grouping by ontology rather than lumping everything together matters because each
ontology has its own tracker. Both paths are gitignored, for the same reason the
source-defect report is: they aggregate every review, so committing them makes
every curation PR conflict.

A row is never in both reports — `test_the_two_reports_partition_the_work` pins
that — and a settled request or a fully adopted change drops out of both while
staying in the TSV as a record.

## What is checked

`check_ontology_change_policy` warns (never blocks) on:

- an obsoletion-with-replacement or a merge that names no surviving term, and a
  non-direct obsoletion that names one anyway;
- a value change (definition, label, synonym, mapping predicate) missing its old
  or new value, or proposing a value identical to the current one;
- an edge change missing the other end of the edge or the relation — reparenting
  a term and adding a `part_of` link to it are different requests;
- a term offered as its own replacement;
- a `COMPLEX_CHANGE` or `OTHER` with a justification under 20 words: those carry
  no KGCL semantics, so the prose is the whole request. KGCL has
  `NodeDirectMerge` but no split, so a class split is a `COMPLEX_CHANGE` whose
  justification must name the terms that should replace it;
- a `request_status` saying the request was filed with no tracker issue linked;
- a `change_dsl` that does not parse, disagrees with the slots, or parses as the
  wrong KGCL class; and one on a change type KGCL's *grammar* has no statement for
  (`NODE_DIRECT_MERGE`, `REMOVE_MAPPING`, `MAPPING_PREDICATE_CHANGE`,
  `COMPLEX_CHANGE`, `OTHER`), where a string that parses must be some other kind.
  Note the distinction: KGCL has classes for four of those five — the enum binds
  them as `kgcl:NodeDirectMerge`, `kgcl:RemoveMapping`,
  `kgcl:MappingPredicateChange` and `kgcl:ComplexChange` — but `kgcl.lark` gives
  no controlled-English form for any of them, which is why `kgcl_dsl()` renders
  nothing rather than a string that looks like KGCL and does not parse;
- a reparenting change (`NODE_MOVE`, `NODE_DEEPENING`, `NODE_SHALLOWING`), or a
  `REMOVE_MAPPING`, whose `old_value` is missing or is not an identifier. That one
  slot carries three different things — the current parent's CURIE for the
  reparenting changes, the withdrawn mapping's source for `REMOVE_MAPPING`, and
  definition, label, synonym or predicate text for the value changes — so its
  string range cannot tell them apart. A mapping removal without it exports an
  upstream request naming no target;
- an `adoption_status: NOT_APPLICABLE` that nonetheless lists
  `affected_annotations`: the value means the change touches no content here, so a
  file list contradicts it;
- a term proposed for obsoletion that the same review `ACCEPT`s on an annotation
  row — `ACCEPT` means the term is a core function of the gene, which contradicts
  asking for its obsoletion. `MODIFY` with the replacement is the consistent pair.

What is **not** checked: whether the ontology should actually make the change,
which no rule can settle, and whether `about_node` exists and carries the stated
label, which is the term validator's job.

## Two shapes, from the two projects backfilled so far

The direct/non-direct distinction is the thing to get right, and the two cases
already recorded show both answers to it.

**`VESICLE_TETHERING_OBSOLETION` — no direct replacement.** Five BPs obsoleted
in favour of one MF, `GO:7770062 vesicle membrane tethering activity`. GO records
**no** `replaced_by` for any of them: a biological process cannot be replaced
outright by a molecular function, so the MF is `consider` and a curator picks the
transport process to hang it on. All five are
`NODE_OBSOLETION_WITH_NO_DIRECT_REPLACEMENT` with one
`has_nondirect_replacement`.

**`AMINO_ACID_ACTIVATION_OBSOLETION` — a direct replacement *and* advice.** 43
substrate-specific tRNA aminoacylation BPs, each obsoleted with a formal
`replaced_by` pointing at its block's generic process (`GO:0006418` cytosolic,
`GO:0070127` mitochondrial) — a BP replaced by a BP, which is legitimate — while
GO's obsoletion comment separately names the 1:1 molecular function to annotate
instead. So each entry carries `has_direct_replacement` (the BP) *and* a
`has_nondirect_replacement` (the MF). Both, on one entry.

The rule that survives both: **what can be repointed mechanically goes in
`has_direct_replacement`; what needs a curator's judgement per annotation goes in
`has_nondirect_replacement`.** Whether the replacement happens to be in the same
GO aspect is a consequence, not the test.

## What is still open

**A large uniform batch should be generated, not transcribed.** Only four of the
43 amino-acid-activation terms are recorded structurally — two cytosolic, two
mitochondrial, enough to pin both shapes. The other 39 are mechanically derivable
from the markdown tables already on that page, and hand-writing them would
duplicate those tables in a second format with no new information. A generator
that reads a project's own tables is the right answer; nobody has written one.

**A mapping removal goes to the wrong recipient.** `REMOVE_MAPPING` on an
InterPro2GO or UniRule mapping is grouped under `ontology: GO`, because GO owns
the term the mapping points at — but the mapping itself lives in InterPro2GO or
UniRule, and that is who would remove it. Five such records are currently filed
under GO. `SourceDefect` has a `resource` slot for exactly this and
`ProposedOntologyChange` does not; adding one would let the exporter route a
mapping removal to the body that maintains the mapping.

**Both pages' audits were stale, and in the same direction.** Vesicle tethering
recorded two of five terms obsolete as of 2026-09-26; all five are. Amino acid
activation recorded all 43 live as of 2026-08-29; the four rechecked are
obsolete. In both cases the ontology moved and the page did not. Nothing here
detects that: `origin` and `request_status` are a curator's assertions, and no check
compares either against the live ontology. `ontology_state_verified` only records
the date someone looked, so it ages without ever contradicting itself. A periodic
audit that re-queries each `about_node` and flags a change still recorded as
`SELF_PROPOSED`, or as `NOT_FILED`, whose term is already obsolete would close the
loop, and would have caught both.
