Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The cohort's **headless discrete-transform service**. Consumes `content.process`

Design of record: [docs/specs/2026-09-29-processor-service-design.md](docs/specs/2026-09-29-processor-service-design.md) (the spec). Tracking issue: #1. **Read the spec before changing behaviour**; it is self-contained and names every co-core symbol used.

**Owns:** transforms (co-core's HTML/PDF/CSV extractors now; org adapters later), `gs://co-gcs-processor`, draining `content.process.dlq`. **Does not own:** spec authoring or interpretation (issuers), fetching (Replicator), change detection/diffing (Watcher), the registry (Archiver).
**Owns:** transforms (co-core's HTML/PDF/CSV/XML extractors now; org adapters later), `gs://co-gcs-processor`, draining `content.process.dlq`. **Does not own:** spec authoring or interpretation (issuers), fetching (Replicator), change detection/diffing (Watcher), the registry (Archiver).

## Development Methodology

Expand All @@ -18,7 +18,7 @@ TDD required. Red → Green → Refactor. No production code without a failing t

Python 3.12, uv, hatchling src layout, pytest, ruff.

**co-core is pinned exactly (`==0.19.7`); Processor owns the pin** (spec D5, §5; since watcher#350 nothing else extracts). `processor_version` = `"<co-core version>+<LOCAL_GENERATION>"`; a pin test makes every bump a deliberate, test-failing act. Never let a dependency bot or `uv lock --upgrade` move co-core. A bump gives Watcher notice (it re-baselines on `processor_version`). It is coordinated with Watcher only if it changes the contract: the `content.process` / `content.derived` models, `canonical_text` semantics, or `resolve_dispatch_essence`. Procedure: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#bumping-co-core).
**co-core is pinned exactly (`==0.32.0`); Processor owns the pin** (spec D5, §5; since watcher#350 nothing else extracts). `processor_version` = `"<co-core version>+<LOCAL_GENERATION>"`; a pin test makes every bump a deliberate, test-failing act. Never let a dependency bot or `uv lock --upgrade` move co-core. A bump gives Watcher notice (it re-baselines on `processor_version`). It is coordinated with Watcher only if it changes the contract: the `content.process` / `content.derived` models, `canonical_text` semantics, or `resolve_dispatch_essence`. Procedure: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#bumping-co-core).

**Cannobserv wheelhouse.** `co-core`, `co-core-aio`, `co-core-sync` resolve from `./.wheelhouse` (git-ignored) via `[tool.uv] find-links`. Populate it before any `uv` command:

Expand Down Expand Up @@ -52,7 +52,7 @@ find-links locks by filename, not hash, so either source satisfies the same `uv.

**Extraction runs in a killable child process** (`python -I -m processor._child`: timeout, `RLIMIT_AS`, `oom_score_adj` 1000, scrubbed env, allowlisting unpickler for its result), never a thread. The child is untrusted: it parses untrusted documents. **It contains itself** (`processor._contain`, #2, spec §3): Landlock (read-only on an allowlist derived from `sys.path`, no TCP, scoped signals) and seccomp (no socket at all). `processor run` is undumpable, and refuses to start where the child can't be contained (`CO_PROCESSOR_CHILD_CONTAINMENT=required`, the default). Keep the child's imports inside the allowlist: a new extractor that reads a data file outside it fails under containment, and the whole-corpus test in `tests/test_child.py` catches that. The test suite runs `required` where the kernel allows it (the pytest header says which), and never skips containment on `co-processor`.

**Parity goldens** come from co-core's pure extract API directly (`scripts/gen_parity_goldens.py`), never from Processor's code. The script imports nothing from `processor`, and a test checks that. The v1 set was Watcher's, and the generator reproduced it byte for byte on 0.19.7 (2026-10-10, #47; `v1_provenance`). A test holds the committed goldens equal to the generator's output on the pinned co-core. Regenerate only at a co-core bump, and list every moved digest in the bump note. A failing parity test means the output diverged; do not regenerate the goldens to make it pass. The real corpus, `tests/fixtures/parity/real/` (#16), is Watcher's export verbatim and the only copy of its blobs (gone from `gs://co-gcs-blobs` after the TTL): never edit or regenerate it; a bump that moves its output says so in the bump note.
**Parity goldens** come from co-core's pure extract API directly (`scripts/gen_parity_goldens.py`), never from Processor's code. The script imports nothing from `processor`, and a test checks that. The v1 set was Watcher's, and the generator reproduced it byte for byte on 0.19.7 (2026-10-10, #47; `v1_provenance`). A test holds the committed goldens equal to the generator's output on the pinned co-core. A spec co-core refuses (`UnsupportedExtractionError`) is a golden too, `{"refused": …}`; any other raise is a generator error (#29). Regenerate only at a co-core bump, and list every moved digest in the bump note. A failing parity test means the output diverged; do not regenerate the goldens to make it pass. The real corpus, `tests/fixtures/parity/real/` (#16), is Watcher's export verbatim and the only copy of its blobs (gone from `gs://co-gcs-blobs` after the TTL): never edit or regenerate it; a bump that moves its output says so in the bump note.

## Agent Skills

Expand Down
4 changes: 2 additions & 2 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,8 +455,8 @@ On boot, the service preflights both buckets and exits non-zero if either is unr

co-core is pinned `==`, and Processor owns the pin (spec D5, §5; amended 2026-10-10, #47). Until watcher#350 it was in lockstep with Watcher. A bump changes `processor_version` on every fact, and Watcher re-baselines on that:

1. Rebuild the wheelhouse at the new tag, then update the pin in `pyproject.toml` and `EXPECTED` in `tests/test_pin.py`, and `uv lock`.
2. Regenerate the goldens from co-core directly: `uv run python scripts/gen_parity_goldens.py tests/fixtures/parity`. It prints every digest that moved. Paste that list into the bump note: each line is a deliberate re-baseline. Never regenerate to make a parity test pass outside a bump.
1. Rebuild the wheelhouse at the new tag, then update the pin in `pyproject.toml`, `EXPECTED` in `tests/test_pin.py` and the default tag in `scripts/build_wheelhouse.sh` (the pin test holds all three), and `uv lock`.
2. A release that adds an essence or an extraction rule gets its cases in `tests/fixtures/parity/cases.json` first, so their goldens come from co-core. Then regenerate the goldens from co-core directly: `uv run python scripts/gen_parity_goldens.py tests/fixtures/parity`. It prints every digest that moved, and every case that started or stopped being refused. Paste that list into the bump note: each line is a deliberate re-baseline. Never regenerate to make a parity test pass outside a bump. A catch-up across releases reads each CHANGELOG's Downstream lines for the extract, bus and blob-store surfaces, and diffs those packages' code between the tags (#29 did 0.19.7 → 0.32.0).
3. The real corpus (`tests/fixtures/parity/real/`) is never regenerated. It passes unchanged, or the bump note says which recorded fingerprints moved.
4. **Notice to Watcher** before deploy: the new `processor_version`, and the moved digests. A change to the contract needs coordination with Watcher first, not just notice. The contract is the `content.process` / `content.derived` models, `canonical_text` semantics and `resolve_dispatch_essence`.
5. Merge, then `scripts/deploy.sh`.
Expand Down
88 changes: 88 additions & 0 deletions docs/plans/2026-10-12-29-co-core-0.32.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
title: Bump co-core 0.19.7 → 0.32.0 — XML and feed extractors, the refusals (#29)
date: 2026-10-12
status: per #29, its re-entry test (met by cannobserv v0.32.0) and the operator's catch-up note (2026-10-10); reviewed 2026-10-12 (subagent: every case below run through 0.32.0's chain, findings folded in)
---

# co-core 0.32.0

## Problem

cannobserv v0.32.0 (tag `v0.32.0`, `33e781a6`) carries cannobserv#523:
- `text/xml`, `application/xml`, `application/rss+xml` and `application/atom+xml` dispatch to a new `XmlExtractor`, where they took the HTML fallback;
- a spec nothing can honour raises `UnsupportedExtractionError(ValueError)`, where it silently meant the whole page;
- `resolve_dispatch_essence` maps `.xml` / `.rss` / `.atom` URLs to the XML essences. That is the contract, so the bump is coordinate-first (D5).

The pin is 0.19.7. Per the operator's catch-up note, whichever of #29 / #37 bumps first takes the full jump.

## The catch-up reading (0.19.7 → 0.32.0)

Checked against the code (`git diff v0.19.7 v0.32.0`), not only the CHANGELOG:
- **Contract models** (`co_core.pure.models.changes`): a docstring only. `extraction_error` now also covers a refused spec; the `Literal` is unchanged.
- **`canonical_text`, `spec_fingerprint`**: unchanged.
- **Bus** (`streams.py`): comments only (`processor.process` documented as live, cannobserv#503/#528). `envelope`, `dead_letter`, `exceptions`, `effects.bus`, `co_core_aio.bus`: unchanged.
- **Blob store** (`util.blobstore`, `util.hashing`, `co_core_sync.drivers.blobstore`): unchanged.
- **Extract**: `xml_feed.py` (new, lxml, already in the extra), `errors.py` (new), the dispatch map, the extension tiebreak, `extraction_config_from_spec`'s refusals, and `refuse_unhonoured` in the HTML, PDF and CSV extractors.
- **Dependencies**: none move. Each package's `pyproject.toml` changes only its version and the sibling floor (`>=0.32,<0.33`).
- **Security fixes in 0.20–0.32**: none recorded.
- Everything else in the range is the WordPress / co/v1 surface, which Processor doesn't import.

So `processors/extract.py` needs no code change: a refusal propagates, the child reports it as `raised`, and the handler publishes `extraction_error`, terminal (spec §4, "Extractor raises").

## Approach

**Pin.** `pyproject.toml` (the three packages), `EXPECTED` in `tests/test_pin.py`, `uv lock`. `LOCAL_GENERATION` stays 1 (ask 2). `scripts/build_wheelhouse.sh`'s default tag follows the pin.

**Refusals are goldens too.** `gen_parity_goldens.py` raises on any case that raises. A case co-core *refuses* is now recorded as `{"refused": "UnsupportedExtractionError"}` (the class name, from co-core's own raise), so the refusal is co-core's output, not Processor's expectation. The catch is `UnsupportedExtractionError` only, imported from `co_core.pure.extract.errors` (inside the import test's allowance): XML and PDF parse failures are plain `ValueError`s, and still name their case and write nothing (a malformed-XML generator test pins that). `moved()` already handles differing field sets, so a spec that starts or stops being refused at a later bump shows in the note.
- `test_extract.py`: a refused golden means `extract()` raises `UnsupportedExtractionError`. The digest test skips refused cases; the no-I/O test runs them too (a refusal does no I/O either).
- `test_child.py`'s whole-corpus run: a refused case comes back `raised`, its detail starting `UnsupportedExtractionError:`, under containment. With the handler's existing `raised` → `extraction_error` row, that is the end-to-end check.

**New cases** (`cases.json`, before regenerating, so their goldens come from co-core):

| Case | Input | Essence | Spec | Covers |
|---|---|---|---|---|
| `feed_rss` | `tailscale_security_bulletins.xml` | `text/xml` | full_page | ask 3. co-core's own trimmed fixture, verbatim; its digest is cross-checked against co-core's hand-pinned `sha256:78e28497…` |
| `feed_rss_rss_essence` | same | `application/rss+xml` | full_page | the essence routes |
| `feed_atom` | `feed.atom` (synthetic) | `application/atom+xml` | full_page | Atom `id` / `updated` / `link rel` |
| `feed_no_items` | `empty_feed.rss` (synthetic) | `application/rss+xml` | full_page | channel fields left out → `empty` |
| `xml_document` | `register.xml` (synthetic) | `application/xml` | full_page | a non-feed root gives its text |
| `xml_xpath` | `register.xml` | `application/xml` | xpath `//entry/title` | xpath honoured on XML |
| `refused_xpath_on_html` | `agenda.html` | `text/html` | xpath `//p` | until cannobserv#557 |
| `refused_css_on_xml` | tailscale | `text/xml` | css `item` | |
| `refused_css_on_pdf` | `minutes.pdf` | `application/pdf` | css `#main` | |
| `refused_css_on_csv` | `licenses.csv` | `text/csv` | css `#main` | |
| `refused_xpath_on_csv` | `licenses.csv` | `text/csv` | xpath `//a` | |
| `refused_regex` | tailscale | `application/rss+xml` | regex | |
| `refused_jsonpath` | `items.json` | `application/json` | jsonpath | until cannobserv#354 |
| `refused_empty_xpath` | `register.xml` | `application/xml` | xpath `""` | |
| `refused_unknown_algorithm` | `agenda.html` | `text/html` | `semantic` | |
| `refused_invalid_xpath` | `register.xml` | `application/xml` | xpath `//[` | |

The `xhtml` case (`application/xhtml+xml`) stays HTML: there is no `+xml` suffix rule.

**Expected moves.** The 13 existing goldens should not move (the HTML, PDF and CSV paths only gained a refusal none of them trips). The real corpus (9 items, all `text/html` + `css`) should pass unchanged.

**Live specs.** The real corpus is the 2026-10-02 export, and the upstream exposure count is from 2026-10-05, so neither proves today's specs. The journal doesn't carry specs, but it shows the same 4 info sources since 2026-10-05, 204 of 204 commands `complete`. The gate is therefore post-deploy: the first command from each of the 4 completes, and no `extraction_error` carries `UnsupportedExtractionError`. Otherwise roll back (DEPLOYMENT.md) and raise it on #29.

**Docs.**
- AGENTS.md: line 21 (the pin), the Owns line (XML joins HTML/PDF/CSV), and the parity paragraph (refusals are goldens).
- Spec: D5 "(0.19.7 today)", the Owns line, §2's dependency row, §4's "Extractor raises" row (names the refusal), §5's pin bullet, §6's cannobserv#503 row (now released), §7's corpus bullet (refusals; XML on the synthetic corpus) and pin-test line, the contract quick reference (heading 0.19.4–0.32.0 after re-checking each row, plus an `UnsupportedExtractionError` row), Open Question 2's version range, and an amendment entry.
- `processors/extract.py`'s module docstring (the parity target is co-core now; a refusal propagates like any raise).
- DEPLOYMENT.md: the bump procedure gains "add new-essence fixtures before regenerating" and "refusals are recorded as goldens".
- Historical 0.19.7 references stay (v1 provenance, the real corpus's `0.19.7+1`).

**Off-repo (each post needs the operator's approval).**
- **Coordinate with Watcher (watcher#366, already filed).** Either order is safe: Processor never re-resolves the essence, and each side alone keeps an ambiguous-header `.xml` URL on the HTML path, today's behaviour. Post on watcher#366 before deploy: the new `processor_version` `0.32.0+1`, the moved digests, and that ordering argument. Deploy once Watcher acknowledges, or when the operator rules that the ordering argument makes acknowledgement unnecessary.
- #29: the bump note (moved digests, real corpus, refusal check).

## Steps

1. Red: the cases and fixtures, the refused-golden handling in the generator test, `test_extract.py`, `test_child.py`, and `EXPECTED = "0.32.0"`. They fail on 0.19.7.
2. Green: the pin and the lock; the generator's refusal record; regenerate; paste the moved list.
3. Docs.
4. Full gate (`ruff check`, `ruff format --check`, `pytest` with the integration marker, `uv lock --locked`), PR with `Refs #29` (the live deploy and the Watcher notice remain).
5. After merge: the post on watcher#366 and its acknowledgement; `scripts/deploy.sh`; the live-spec gate above; the drift check reads current.

## Open questions

None blocking. The live fetch of Tailscale's feed (46 items, 146 KB) isn't added: co-core's trimmed copy is the same feed, and its digest is independently pinned upstream.
Loading
Loading