Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 18s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Docs update reminder (pull_request) Successful in 21s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 11s
review-verdict/h10 Review-verdict: MERGEABLE @ 95b2700 (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 23s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 8m6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m35s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 2m56s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
Both issues are #701 deferrals, and they land together because both rewrite the same decision record. #823 -- can a null reach one of the six collection-valued scalar columns? MEASURED against a real TvContext on BOTH providers (SQLite, and MySQL 8.4 on an ephemeral server), because the reasoning available beforehand pointed the wrong way. The two converters differ on their read side -- IntCollectionValueConverter maps null-or-blank to Array.Empty<int>(), while EnumCollectionJsonValueConverter would dereference the result of JsonConvert.DeserializeObject -- so the expectation was that a NULL row behaves differently per column. NEITHER RUNS: EF does not invoke a value converter for a NULL column at all. All six materialize as CLR null, the int converter's null-to-empty branch is dead on this path, and unguarded each .Contains in AlternateScheduleSelector throws NullReferenceException. A NULL reads as UNRESTRICTED -- the All*() sets -- not as empty. This is the whole semantic question and the first draft got it backwards. It is decided by the one NULL reachable WITHOUT any code writing one: Sqlite's 20240113140741_Add_PlayoutTemplate_DaysOfMonth adds the column with nullable:true and NO defaultValue, so a PlayoutTemplate row inserted before it holds NULL and by construction had no day-of-month restriction. Reading that as empty INVERTS the row's meaning and silently stops the template applying at all. All*() preserves it, and is how "no restriction recorded" is already represented (GetPlayoutAlternateSchedulesHandler, PreviewBlockPlayoutHandler). What does NOT decide it, and was wrongly cited in the first draft: the API request records normalize an omitted field with `?? []`, but that is a client omitting a field on a WRITE and says nothing about what a legacy database NULL meant. Two read sites, not one. Guarding only the selector would have left the entity->DTO mappers unguarded, and those feed the SPA: PlayoutScheduleEditors spreads the collection (`[...template.daysOfMonth]` -> TypeError on a JSON null) and playoutTemplateCalendar's appliesToDate -- an exact port of GetScheduleForDate -- calls .includes on it. Both mappers now substitute the SAME defaults, so the preview agrees with what is actually scheduled. Neither guard is assigned back onto the entity, which is the media.nullable-primitive-collection-mutation mechanism. Reachability, stated precisely rather than overclaimed. All six are nullable:true on both providers, but a nullable column does not produce a NULL row: five of the six were present at CreateTable, so a NULL there still needs code to write one, and on MySQL there is NO code-path-free NULL for any of the six. The write path ACCEPTS a null (SaveChanges succeeds, stores SQL NULL) but no caller supplies one today -- every production construction of the two commands goes through the request records. That is a property of the code, not a live caller; claiming otherwise would be the banned "it's AsNoTracking today" argument pointed the other way. #824 -- ElasticSearchIndex.UpdateSong had no regression test Issue option 1 (a non-network transport) shipped, and needed no new package: Elastic.Transport.InMemoryRequestInvoker is public in the pinned version and ElasticsearchClientSettings(NodePool, IRequestInvoker) accepts it, injected into the private _client the way #701 injects the Lucene IndexWriter. UpdateItems never runs `_client ??= CreateClient()`, so the injected instance is the one used. Two traps there are load-bearing, both measured: the canned response must carry an `X-Elastic-Product: Elasticsearch` header or the client's product check throws UnsupportedProductException INTO UpdateSong's catch, and an empty body fails to deserialize the same way. Either turns the fixture into a green measurement of the error path -- which is how it first failed here, caught by the ThrowOnWarningLogger. The document id is asserted as the LAST PATH SEGMENT, not by substring: the index name carries digits, so ShouldContain would stop discriminating for a song whose id collided with one. Six mutations executed, each disarming ITS OWN clause alone: - `??=` restored in ElasticSearchIndex only -> the Elastic fixture reddens on "metadata.Artists should be null but was []" while the LUCENE fixture stays GREEN. The #824 hole demonstrated, not described. - DaysOfWeek guard disarmed in the selector -> 4 red, 3 green (DaysOfMonth and MonthsOfYear unaffected). Each clause is independently load-bearing. - DaysOfMonth guard disarmed in Playouts.Mapper -> 1 red, 2 green. - Elastic dropped from the covered set / mapped to the SAME fixture as Lucene / mapped to a class with no [Test] -> SearchIndexMutationCoverageTests reddens on each. That coverage guard is the boundary fix the issue asked for: the covered set is compared against an ISearchIndex population DERIVED FROM THE ASSEMBLY. Its claim stops where the check does -- no static check can establish that a named fixture actually DRIVES its indexer, so it forces a human to look rather than proving coverage. ThrowOnWarningLogger moved to ErsatzTV.Tests/Support so both fixtures share it; the Lucene fixture's assertions are otherwise untouched, since it is a witnessed proof artifact. No production change in ElasticSearchIndex.cs -- #824 is coverage only. Docs: testing.md gains a "Provider-parity fixtures" section naming all THREE opt-in-MySQL fixtures and recording that CI runs none of them (#627); docs/README.md gains the matching task signal; guard-inventory.md's hand-written C# guard list goes from five files to six. Scheduling/Mapper.cs loses the UTF-8 BOM it inherited, per #311 fix-as-you-touch. Local gate (with the MySQL lane armed): ErsatzTV.Tests 2096 passed / 0 skipped, Core.Tests 693/1, Infrastructure.Tests 114, Architecture.Tests 7, Scanner.Tests 1504 -- 0 failures in each. scripts/tests 1228 passed / 2 skipped. dotnet format whitespace --verify-no-changes clean; BOM check over the touched set with the population COUNT asserted, because a bare zsh loop silently checks one concatenated filename. decisions_validate OK. Fixes #823 Fixes #824 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
150 lines
16 KiB
Markdown
150 lines
16 KiB
Markdown
# docs/ — task-signal map
|
|
|
|
Purpose: route a fresh contributor/agent to the minimal set of docs for the task at hand, instead of
|
|
a mandatory front-to-back read. **Update this doc in the same PR that adds, removes, or retitles a
|
|
doc below, or that changes which sections a task signal points to.**
|
|
|
|
## Start here, always
|
|
|
|
- **`CLAUDE.md`** (repo root) — project intro: architecture, layout, dev commands, conventions,
|
|
Task Completion Protocol.
|
|
- **`docs/contributing.md`** — established code patterns (CQRS/MediatR, LanguageExt, the ChicoryTV
|
|
SPA, EF Core dual-provider migrations, FFmpeg pipeline, analyzers, testing). Read before any
|
|
non-trivial change.
|
|
|
|
## Task signal → minimal sections
|
|
|
|
| Signal | Read |
|
|
| --- | --- |
|
|
| Session startup / "what's next" (no issue named) | `docs/handoffs/chicorytv-issue-queue.md` (standing kickoff — two concurrent tracks: orientation ‖ `scripts/select-queue.sh 5`) |
|
|
| Named-issue pickup | Skip queue selection; go straight to focused retrieval — see "Knowledge retrieval" below, then the issue body |
|
|
| Adding/changing a `/api/*` endpoint | `docs/api-conventions.md` checklist + `docs/endpoint-index.md` |
|
|
| Adding a ChicoryTV SPA screen | `docs/spa-conventions.md` |
|
|
| Explaining a consequential settings field in the SPA (summary → hover/tap panel → docs link) | `docs/spa-conventions.md` §15 — use the shared `FieldHelp` trigger and put the copy in the screen's own `FIELD_HELP` record; the icon, the gesture and the a11y contract are fixed |
|
|
| Graphics element / overlay work (text bug, On Now / Next, watermark-vs-`[vge]`) | `docs/graphics-elements.md`, then decisions catalog rows keyed `graphics.*` |
|
|
| Scheduling / playout engine work | `docs/domain-model.md` + decisions catalog rows keyed `sched.*` (`docs/decisions/README.md`) |
|
|
| Adding or changing a paged list handler (a page plus a `TotalCount`) | Resolve `api.paged-count-matches-page-query` via `docs/decisions/README.md` — for an EF-backed filtered list, count the SAME query you page, with includes appended to the page chain only; where the count and the page are separate methods, a test pins their agreement. Then `api.paging-zero-based` for the `pageNum`/`pageSize` contract |
|
|
| Concurrency / optimistic-locking work | `docs/api-conventions.md` §7a/b/c + `docs/decisions/optimistic-concurrency.md` |
|
|
| Auth / security-surface work | `docs/decisions/api-auth-security.md` |
|
|
| CI / release pipeline work | `docs/ci-cd.md` + `docs/decisions/release-ci-governance.md` |
|
|
| Proposing a new guard / CI check / regression test convention | `docs/defect-shapes-773.md` §4 (detector menu + the classes where no detector is plausible), then the rules every guard must satisfy: `docs/decisions/records/testing/guard-derives-population-from-source.md`, `…/guard-ships-with-mutation-proof.md` and `…/mutation-claims-are-executed.md` (a `MUTATION` grade carries a DECLARED clause mutation that is re-run every suite) — plus `…/verification-code-needs-its-own-proof.md`, which extends the same obligation BEYOND guards to the harness, wrapper or checker doing the checking, and says where its proof lives when the checker holds no row |
|
|
| Adding or bounding a consequential numeric config field (an FFmpeg profile tunable, a pipeline knob) | `docs/api-conventions.md` §3d — reject out of range with a 422 naming the bound and its consequence, never accept-then-rewrite; validate against the constants the renderer reads, keep the render-time clamp for pre-existing rows, and let an UNCHANGED legacy value through on update. Then `api.ffmpeg-profile-numeric-bounds` |
|
|
| Testing a surface gated by config / an env var / a credential | `docs/decisions/records/testing/deny-path-at-production-config-value.md` — cover the setting absent, at its production value, and each opt-out, and assert the DENY branch |
|
|
| Touching a full-replace write path or a hand-built request object | `docs/decisions/records/testing/full-replace-asserts-field-list.md` — derive the field list from the DTO and assert set equality; reconcile by id where child state exists. In the SPA the same rule is enforced by the type system: `docs/spa-conventions.md` §4b — build the body as `Complete<T>`, annotating BOTH the wrapper parameter and every construction site |
|
|
| Writing or editing any doc, or answering a review finding in prose | `docs/decisions/records/docs/no-session-narrative.md` — the doc records the END STATE; the path to it goes in the commit message. Apply the who-benefits test, and read the carve-out before you cut (dated measurements, stated snapshot boundaries and tested-and-rejected results stay) |
|
|
| Adding, renaming or removing a workflow JOB | `docs/ci-cd.md` → "Per-job declarations" — every job declares `env.CI_JOB_ROLE` (and, in `docker-build.yml`, `env.CI_EXECUTION_CLASS`); a missing or unknown value fails `scripts/tests/test_workflow_job_guards.py` / `…/test_ci_image_pin_population.py`, and a `guard` **or `report-only`** job also needs a row in `docs/guard-inventory.md` → "Workflow-job guards". Rationale: `docs/decisions/records/testing/workflow-declares-its-own-job-metadata.md` |
|
|
| Adding / changing / deleting a guard file | `docs/guard-inventory.md` — every guard's row is machine-checked by `scripts/tests/test_guard_inventory.py`, so a new guard must acquire a row before the suite goes green, and a row graded `MUTATION` must also acquire a declared clause in `scripts/tests/mutation_manifest.py` |
|
|
| Writing code that reads live Gitea/remote state and then acts on it | `docs/decisions/records/process/check-and-use-pins-a-version.md`, then `docs/remote-state-inventory.md` — a new executable under `scripts/` (**excluding `scripts/tests/`**), `.claude/hooks/`, `.husky/` or `.gitea/workflows/` must acquire a row there before `scripts/tests/test_remote_state_inventory.py` goes green |
|
|
| Finding every site that references a symbol (multi-site fix/sweep) | `docs/local-lsp-tooling.md` — which of the three surfaces answers, and why a delegated agent must be pointed at an MCP server (`csharp-lsp`, or `serena` after an `activate_project`) rather than the `LSP` tool, which no subagent has been observed to reach |
|
|
| Live local run / Playwright-MCP verification | `docs/e2e-local.md` + `scripts/e2e-local.sh` |
|
|
| Adding/changing a UI-E2E browser flow | `docs/e2e-local.md` → "UI-E2E harness" + `scripts/e2e-ui.sh` |
|
|
| What does a test suite cover | `docs/testing.md` |
|
|
| Writing a test whose behaviour is PROVIDER-SPECIFIC (collation, a value converter, data-migration DML) | `docs/testing.md` → "Provider-parity fixtures (opt-in MySQL)" — run one fixture body against both providers via `ETV_TEST_MYSQL_CONNECTION`; without it the MySQL arm `Assert.Ignore`s visibly, and CI does not currently run it (ersatztv#627) |
|
|
| Legacy Blazor route lookup | `docs/blazor-route-parity.md` (historical #91 phase (b) inventory) |
|
|
| "Why do we do X this way" / challenging a convention | **Catalog-first**: `docs/decisions/README.md` (active rows) → follow the row's link to `docs/decisions/records/<area>/<topic>.md` for full rationale. `docs/decisions/archive/<area>/` only for "what did the rule used to be." |
|
|
|
|
## Knowledge retrieval (MemPalace + catalog + Gitea)
|
|
|
|
These four rules are the seam agreed with server-management#642 (the Gitea→MemPalace exporter).
|
|
They apply whether the question comes up via MemPalace, a grep, or a stale comment:
|
|
|
|
1. **Current conventions/decisions → catalog-first.** Start at `docs/decisions/README.md`; discover
|
|
via the `ErsatzTV-Decisions` wing (active) / `ErsatzTV-Decisions-Archive` (superseded/retired).
|
|
**Resolve by topic/key, never by chasing a file path.**
|
|
2. **Issue history → evidence, not authority.** The `Gitea-ErsatzTV` wing is historical narrative
|
|
that may be stale; it never overrides current Markdown.
|
|
3. **The breadcrumb rule (the crux behavior change).** A file path named inside a *historical issue
|
|
comment* (e.g. "grep `docs/decisions.md` 2026-07-17", "see …") is a **breadcrumb, not a live
|
|
pointer.** Find the current rule via the catalog / active wing **by concept**; do not treat the
|
|
named path as current. (Why it's safe: still-current → in the active wing, breadcrumb resolves;
|
|
superseded → the active wing returns the *successor* and a literal follow lands on a record that
|
|
announces its own `status: superseded`; retired → the active wing returns nothing, which is
|
|
itself the signal. The validator-enforced move-to-`archive/` is what prevents the catastrophic
|
|
"superseded rule read as current" case.)
|
|
4. **Fallback when MemPalace is stale/down:** `docs/decisions/README.md` catalog, then
|
|
`` rg '^`key: <dotted.key>`' docs/decisions/ ``. MemPalace is never authority nor sole fallback.
|
|
|
|
MemPalace is candidate discovery only — every passage is verified against its cited Markdown/Gitea
|
|
source before use. Never derive live queue state from MemPalace, #237, or historical comments; queue
|
|
state is live Gitea state, retrieved via `scripts/select-queue.sh` (see
|
|
`docs/handoffs/chicorytv-issue-queue.md`). Full retrieval contract (altitude/precedence, staleness
|
|
bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "Knowledge retrieval".
|
|
|
|
## Also present in `docs/`
|
|
|
|
- **`docs/domain-model.md`** — what the app IS: entity glossary, channel→playout→schedule/block
|
|
concept map, where each concept is edited in the SPA.
|
|
- **`docs/api-conventions.md`** — checklist for adding/changing a `/api/*` endpoint (controllers,
|
|
DTOs, error mapping, auth, OpenAPI regen, tests).
|
|
- **`docs/spa-conventions.md`** — playbook for adding a screen to the ChicoryTV React SPA.
|
|
- **`docs/e2e-local.md`** (+ `scripts/e2e-local.sh`) — how to run a live local instance for manual
|
|
or Playwright-MCP verification.
|
|
- **`docs/local-lsp-tooling.md`** — the code-intelligence surfaces (the `LSP` tool's three servers,
|
|
the `csharp-lsp` MCP server, and `serena`): how each is configured, which ones a **subagent** can
|
|
actually reach, the traps (a cold server answers the first query with a confidently partial result;
|
|
serena needs an `activate_project` per directory), and `scripts/check-local-lsp.sh` to verify the
|
|
preconditions. Read before briefing an agent to find every site referencing a symbol.
|
|
- **`docs/testing.md`** — testing map: what each `*.Tests` project / `web` suite covers,
|
|
golden-file nets, the timezone-independence rule, how to run subsets, the per-PR verification
|
|
gate.
|
|
- **`docs/blazor-route-parity.md`** — historical record of the completed #91 phase (b) cutover:
|
|
the Blazor Server UI is removed and every legacy route now 302-redirects to its SPA equivalent
|
|
(or falls through to the catch-all → `/app`). Read it for the full legacy→SPA route inventory.
|
|
- **`docs/decisions/records/<area>/<topic>.md`** — one active decision record per file, YAML
|
|
frontmatter (`key`/`title`/`status`/`since`/`supersedes`/`superseded-by`, plus optional
|
|
`stale-after`/`sources` — ersatztv#603), rationale prose in the body. The **filename is the key**,
|
|
so one-active-record-per-key is a filesystem property (ersatztv#610). `docs/decisions.md` and the
|
|
topic files remain as the lifecycle-schema narrative plus a "Records formerly in this file" index,
|
|
which is what keeps older date-based pointers resolvable. **Generated active view**:
|
|
`docs/decisions/README.md`
|
|
(catalog / task router) — start there. Superseded/retired records live in
|
|
`docs/decisions/archive/` and are read only for history, never for "what is the current rule."
|
|
- **`docs/ci-cd.md`** — build/test/release pipeline, versioning, dependency management.
|
|
- **`docs/rest-api.md`** — REST API design doc for ersatztv#2 (goals, conventions, per-slice plan).
|
|
Largely superseded day-to-day by `docs/api-conventions.md`; read this for the original rationale.
|
|
- **`docs/mcp.md`** — the `ErsatzTV.Mcp` stdio JSON-RPC MCP server (#58): how it wraps `/api/v1` as
|
|
read + cautious-write tools, its config/env vars, auth, security posture, and the tool catalog.
|
|
- **`docs/graphics-elements.md`** — graphics element (overlay) schema reference: how elements are
|
|
discovered and attached, the YAML parsing traps (an unknown key disables the element outright), the
|
|
full text-element field table including the #732 background box, and why `[vge]` in a filter graph
|
|
does not imply a graphics element is bound.
|
|
- **`docs/channels.md`** — Channel entity field reference.
|
|
- **`docs/m3u-xmltv.md`** — M3U/XMLTV generation overview (`ChannelPlaylist`, `GetChannelGuideHandler`).
|
|
- **`docs/fork-strategy.md`** — divergence policy vs upstream ErsatzTV.
|
|
- **`docs/design-sync.md`** — Claude Design ↔ repo screen workflow (#92).
|
|
- **`docs/endpoint-index.md`** — generated REST endpoint index (method/path/operationId/summary per
|
|
OpenAPI tag). Do not edit by hand; regenerated by `scripts/generate-endpoint-index.py` /
|
|
`scripts/update-openapi.sh`.
|
|
- **`docs/handoffs/chicorytv-issue-queue.md`** — static session kickoff prompt + workflow lore.
|
|
Queue state is **live Gitea state**, retrieved each session via `scripts/select-queue.sh` — see
|
|
that file's standing kickoff for the two concurrent tracks (orientation ‖ selection). ersatztv#237
|
|
is a closed, archival historical tracker (superseded by `startup.parallel-orientation` in
|
|
`docs/decisions.md`) — not a live pointer.
|
|
- **`docs/defect-shapes-773.md`** — root-cause analysis of the recurring defect shapes across the
|
|
whole closed-issue corpus (#773): the measured class ranking, the four families they consolidate
|
|
into, the cheapest mechanical detector per class, the classes where **no** detector is plausible,
|
|
and an audit of which configured hooks/MCP servers/LSPs are actually invoked. Read it before
|
|
proposing a new guard or CI check — §4 is the detector menu, and it argues against enumerating
|
|
cases one incident at a time.
|
|
- **`docs/remote-state-inventory.md`** — every executable in `scripts/` (**excluding
|
|
`scripts/tests/`**), `.claude/hooks/`, `.husky/` and `.gitea/workflows/` that reads live remote
|
|
state and acts on that read, classified `PINNED` / `CAS` / `UNSAFE-KNOWN` / `N/A` with the window
|
|
and what bounds it. Code outside those directories — C#/TypeScript guards, `web/`, and the test
|
|
suites themselves — is out of scope, and the doc states that rather than implying coverage.
|
|
The population is derived from `git ls-files` and compared for set equality by
|
|
`scripts/tests/test_remote_state_inventory.py`, so a new script that talks to a remote service
|
|
cannot ship unclassified. Read it with `process.check-and-use-pins-a-version`; it is that record's
|
|
detector, since the class has no plausible linter (`docs/defect-shapes-773.md` §4 detector D).
|
|
- **`docs/guard-inventory.md`** — every executable guard file, what it blocks, whether it is a
|
|
`GUARD` or `TOOLING`, and whether it ships a mutation proof (`MUTATION` / `BEHAVIOUR-ONLY` /
|
|
`NONE`) with a `file::function` ref. The population is derived from the GIT INDEX (not a
|
|
filesystem walk, since ersatztv#806) and the workflow/hook call sites, and compared for set
|
|
equality by `scripts/tests/test_guard_inventory.py`,
|
|
so a new guard cannot ship unclassified and a renamed test cannot leave a row claiming coverage it
|
|
has lost. Guards implemented inline in workflow YAML are deliberately outside that population —
|
|
the doc states the limit rather than implying coverage.
|
|
- **`docs/tracker-retrofit-triage-237.md`** — audit trail for the #524 triage of ersatztv#237's 111
|
|
comments (method, per-comment classification, totals). Evidence for the
|
|
`docs.tracker-comment-retrofit` decision; read it only when triaging another over-cap tracker.
|
|
- **`docs/handoffs/rest-api.md`** — original handoff prompt for kicking off the REST API work (#2).
|