feat(api): editorial_score + sentiment_score (schema v2)#361
Merged
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Hfd5GY7STjojT7kSrZSqF
…tory v2) The items/news pipeline's newsworthiness score is now editorial_score, disambiguating it from the signals pipeline's sentiment score. Task 3 renamed the producer; this renames the reader side to match. Both copies of the input trust boundary move together in this commit — Heartbeat's validation_gate and its port in signals_views._validate_items — so the two gates never disagree about what input is valid. Legacy score-only batches are a missing required field: they poison the batch (404), a strict cutover with no compat shim. Parity guard extended with two strictness cases and mutation-tested: a one-sided flip of either copy's REQUIRED_FIELDS fails the parity suite. signals-fixture.json regenerated: the artifact embeds the generator VERSION, which this commit bumps. Exactly one line changes (.generator) — the other 42 fields are identical, confirming the rename moves no signal values. The signals writer's own score (LLM reply key, diagnostics.scores_damped, artifact entries) is untouched — that is a separate contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Hfd5GY7STjojT7kSrZSqF
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Hfd5GY7STjojT7kSrZSqF
Historical v1 disk artifacts (score) are normalized to v2 (sentiment_score) at the response boundary, whether served as latest or via ?as_of, so `score` never reaches the wire. Entry dicts are copied before mutation since they're shared references into the per-request memoized artifact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Hfd5GY7STjojT7kSrZSqF
Normalize per entry regardless of the artifact's schema_version, and drop a stray score rather than let it overwrite an authoritative sentiment_score. The view is the only boundary — nothing validates artifacts at runtime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Hfd5GY7STjojT7kSrZSqF
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Hfd5GY7STjojT7kSrZSqF
The banner described the items rename while citing only §4.2/§4.4, neither of which defines it; §4.1's pinned items contract is where `score` -> `editorial_score` actually lands. §4.3's LLM reply key is called out as deliberately unchanged so it is not "fixed" later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Hfd5GY7STjojT7kSrZSqF
The validator keyed only on artifact identity, so a wire-shape change left it byte-identical while the body changed — a conditional request could 304 a client into a stale v1 body carrying `score`, permanently for ?as_of reads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Hfd5GY7STjojT7kSrZSqF
If-Modified-Since alone bypasses the ETag entirely (Django conditional response step 4), so a frozen generated_at would 304 a client into a stale v1 body despite the salted ETag. Native-v2 artifacts keep the validator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Hfd5GY7STjojT7kSrZSqF
Collaborator
Author
Deviations from the plan (all deliberate, all reviewed)Three of the plan's own instructions turned out to be defective. Each was verified against the code before deviating:
Two defects the final whole-branch review caught (fixed here: 3b35c7c, ad9c05d)Neither was visible to any per-task review, since each saw only one commit.
Accepted residual: Follow-up before ATL PR-2 (which deletes that fallback): grep ATL for |
The page said Last-Modified is sent "only on unfiltered responses", which a reader takes as "always, when unfiltered". Since ad9c05d it is also withheld from artifacts predating the rename — permanently for ?as_of reads — so the sentence under-described when the header is absent. Also record that _SIGNALS_WIRE_SCHEMA_VERSION now gates three things, not one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Hfd5GY7STjojT7kSrZSqF
§4.2 and §9 still pointed contract tests at `signals-v1.schema.json`, which this branch made false: v1 pins the superseded `score`, so it rejects current output rather than validating it. §4.4's `Headers` bullet — the contract `signals_views.py` names in its own docstring — still described both conditional validators in their pre-rename shape, missing the ETag version salt and the `Last-Modified` schema gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbs5wnQZhTi9CQ3pb14vDo
`_load_artifact` validates that `signals` is a dict but never each entry's type, so a corrupt entry does reach the wire normalizer, whose `isinstance` guard was reachable but untested. It closes two separate raises, because `"score" not in entry` means something different per type: a TypeError on an int, and a *substring* test on a str — so an entry merely containing "score" slips through into `dict(entry)` and raises ValueError. Either would 500 an endpoint whose every failure path is contracted to be a 404. Also records why `_items_etag` needs no schema salt, unlike `_etag`: the items poison pill makes every pre-rename batch unservable, so no stale-representation window exists for a salt to close. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbs5wnQZhTi9CQ3pb14vDo
Nothing has loaded it since 8e40d22 repointed both suites at v2, so an unannotated in-tree schema reads as the live contract. A `$comment` records that it describes what is *stored*, not what is served: pre-rename artifacts stay on disk and are still reachable via `?as_of`, but the view normalizes them up to v2 at the wire. `$comment` is a draft-07 annotation, so this cannot change a validation outcome. The `window_hours` comment in `test_news_signals.py` still named v1 after the same commit repointed `SCHEMA_PATH`; v2 pins the same floor, so it now names no version. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbs5wnQZhTi9CQ3pb14vDo
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.
Disambiguates the two unrelated
scorefields (spec:Docs/superpowers/specs/2026-07-14-score-field-disambiguation-design.md).editorial_score, news-story v2 — strict, legacy batches poison-pill (404)sentiment_score, pinned by newsignals-v2.schema.json; the view normalizes historical v1 so the wire is uniformly v2, including?as_ofreadsPROMPT_VERSION,diagnostics.scores_dampedand ATL-internal names unchangedMerge gate: ATL PR-1 (
Open-Finance-Lab/AgenticTrading#112,sentiment_scorefallback) must be merged and deployed first. Merging here auto-deploys Heartbeat + Django concurrently — run the plan's Task 9 runbook right after (it was corrected on main in0581d9a: the manual kick can exit 0 without writing a batch, and the rollback is symmetric for items but not for signals).Tests: Heartbeat 172 OK; backend 780 passed / 1 skipped / 40 subtests.
Beyond the plan, three defects in the plan's own code were found and fixed — see the review notes below.
🤖 Generated with Claude Code
https://claude.ai/code/session_012Hfd5GY7STjojT7kSrZSqF