Round 6 found three more false greens and named the class they share, which is worth
more than any of the three fixes:
* `web/vitest.config.ts` OUTRANKS the pinned `vite.config.ts` — closed in the previous
commit, found by probing vitest rather than reading about it.
* A DECOY first `test: {` block. The comparison took `text.index("test: {")`, so a copy
of the pin placed above `defineConfig` satisfied it while the real block was narrowed.
Exactly one is now required — the same assertion this file already made about the
gating step's NAME, for the same reason, not carried across.
* A `needs:` edge matched by bare job id. `needs:` resolves within its own workflow, so
a SECOND workflow publishing this Dockerfile while needing its own unrelated job
called `test` satisfied it. Now bound to `GATING_WORKFLOW`. (The reviewer downgraded
this to MEDIUM on measuring that `test_remote_state_inventory.py` forces a human to
classify any new workflow — so the hole is "the guard is blind", not "silent". The
forced review asks about remote state, not about whether the image is gated, so the
one-line fix stands.)
* A vite PLUGIN can shell out to the suite from `buildStart()`. The plugin ARRAY is
pinned; the plugin BODIES are a stated residual, mitigated because
`trackedSourceFilesPlugin` is deliberately lazy — a fact its own comment now marks as
LOAD-BEARING for the image build rather than leaving as an optimisation note.
THE CLASS: **a pin assumes it is pinning the artifact that still decides.** Every route
found so far is authority moving where the pin is not looking — to another FILE, another
OCCURRENCE in the same file, another WORKFLOW, or a HOOK the pinned command invokes. That
question is now written down for the next person adding a pin, because a list of four
instances is not what generalises.
Prose, all refuted by execution: the residual naming the uncovered COPY shapes was wrong a
THIRD time at the same site (`/source/web /elsewhere` IS recognised — only the destination
is renamed — and the file's own test 700 lines below said so); "only an `ENV` is
unmodelled" was an absolute and is now a list; "Reach: N mutants, 0 missed" is restated as
a DEVELOPMENT BATTERY, since it is not in the repo, nothing re-derives it, and an
independent battery found misses against an earlier head; and `PUBLISH_ACTION` was claimed
covered by anti-vacuity, which proves the selector is non-empty and cannot prove it
complete.
Battery 61 -> 64, 0 missed.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
196 KiB
196 KiB
Active decisions — catalog / task router
The compact current view of settled decisions. Each row is an active record; follow
the link for rationale. Superseded/retired history lives in archive/. Regenerated by
scripts/build_decisions_catalog.py.
| Key | Current rule | Since | Record |
|---|---|---|---|
api.absent-collection-means-unrestricted |
A nullable collection on a write path carries TWO distinct requests and they get DIFFERENT answers. ABSENT (property missing, or explicit null — Newtonsoft maps both to null, so they are indistinguishable and are treated as ONE case) means the client expresses NO restriction and NORMALIZES to the PERMISSIVE value; EXPLICITLY EMPTY ([]) is a well-formed request whose outcome is meaningless for a conjunctive filter, and is REJECTED with a 422 naming the consequence rather than rewritten. Collapsing the two is what ersatztv#880 records: ?? [] on the three recurrence arrays of PlayoutAlternateScheduleItemRequest / PlayoutTemplateItemRequest returned HTTP 200 and stored a row that could NEVER apply on any date, because AlternateScheduleSelector.GetScheduleForDate treats DaysOfWeek/DaysOfMonth/MonthsOfYear CONJUNCTIVELY (a miss on any one continues), so empty is the maximally RESTRICTIVE value, not a neutral one. The permissive value is not chosen freshly: it must be the SAME SYMBOL the read side substitutes for the same absence — here AlternateScheduleSelector.All*(), already used by the selector's null guard and both Mapper.ProjectToViewModel overloads (media.nullable-primitive-collection-mutation), whose residual (3) this closes. Distinguishing absence from empty REQUIRES the property to be nullable (List<T>?), which in the ErsatzTV web project needs a per-file #nullable enable — the project has annotations off, and sibling request records already use that form. THE TWO HALVES LIVE IN DIFFERENT LAYERS AND THAT SPLIT IS THE DECISION: normalization sits in the request record's ToReplaceItem, but REJECTION SITS IN THE HANDLER, in RecurrenceSetBounds (ErsatzTV.Application/Scheduling) called from BOTH replace handlers — the FFmpegProfileBounds shape. It CANNOT sit beside the normalization in the controller, because api.ffmpeg-profile-numeric-bounds' rule that an UNCHANGED bad value must still be accepted binds here in its sharpest form: both PUT paths are WHOLE-LIST replaces, so rejecting a pre-existing empty set would make every OTHER item in the list uneditable over a row the operator never touched. That comparison needs the STORED row, which the controller does not have and the handler already loaded. The exemption is PER FIELD, not per row — a row grandfathered on DaysOfWeek still cannot newly empty MonthsOfYear — and a STORED NULL is NOT exempt, because null means unrestricted, so submitting [] against it is a new emptying. The validated set is DERIVED from the list the handler actually writes (incoming), which EXCLUDES the highest-Index catch-all: the handler discards that item's recurrence along with its date range, so an empty set there cannot make anything "never apply" and rejecting it would state a reason that is FALSE for that item — do not re-derive "which item is the catch-all" in a second place (api.put-replace-index-order). Finally, the error message says SEND NULL, not "omit the property": making a C# property nullable does NOT make it optional in the generated schema — all three are still listed in required in v1.json with type ["null","array"] — so a client generated from the published contract cannot omit them, and telling it to would instruct a schema violation. Omission still works at runtime; null is the form that is also contract-legal. |
2026-08-30 | link |
api.artwork-rooted-urls |
API response DTOs return artwork as rooted, directly-usable URLs (plus passthrough for absolute/proxy URLs), never relative Blazor-convention paths. | 2026-07-07 | link |
api.async-op-contract |
Queue-triggering /api/* endpoints normalize onto one contract — 202 Accepted (queued), 404 (missing entity), 409 (lock held), 422 (domain precondition) — with Trakt as the reference implementation; playout list/detail GETs also carry an isLocked observability flag as the HTTP-observable substitute for a live push channel. |
2026-07-11 | link |
api.channel-health-object |
ChannelResponseModel/ChannelDetailResponseModel carry a server-derived health object (ChannelHealthResponseModel { Status, Faults[], PlayoutCount, BrokenSourceItemCount }) computed read-time from the built timeline (Playout.BuildStatus + upcoming PlayoutItem → MediaItem.State, Finish >= now), kind-agnostic across all 5 PlayoutScheduleKind values; Status/Faults are const-string classes (ChannelHealthStatus, ChannelFault), not C# enums, so the SPA hand-maintains the union (mirrors ChannelPreviewAvailability). This supersedes #72's "raw fact only, no derived enum, empty-schedule/broken-source deliberately not computed" stance now that the auto-tune taxonomy churn (#383/#384) it was waiting on has landed (see channel.origin-marker sibling record, #414). |
2026-07-23 | link |
api.channel-preview-capability |
Whether a channel can be previewed in the browser is declared by the server, not derived by the SPA, as an additive Preview field ({Availability, ManifestUrl, UnavailableReason}) on ChannelResponseModel. |
2026-07-21 | link |
api.decode-by-id |
Endpoints that decode/expand opaque stored state accept a database row id and resolve it server-side rather than round-tripping client-supplied serialized state. | 2026-07-07 | link |
api.ffmpeg-profile-numeric-bounds |
A write path that receives an out-of-range value for a consequential numeric FFmpeg profile field returns 422 naming the bound AND the consequence of exceeding it, instead of storing a substitute and returning 200. FFmpegProfileBounds (ErsatzTV.Application/FFmpegProfiles) is the single validator, called from both the create and the update handler, and it validates against constants declared on FFmpegState beside the render-time resolution rather than restating numbers — MinimumQsvExtraHardwareFrames, Minimum/MaximumReadRate and MaximumReadRateCatchup are read by BOTH the validator and the renderer, while MinimumReadRateCatchup is write-path-only (at render time the resolved base rate is always at least MinimumReadRate, so it can never be the binding floor). THE RENDER-TIME CLAMPS STAY: they cover rows written before this validation existed or out of band, and keeping them is what makes the change migration-free. ON UPDATE, only a NEWLY submitted out-of-range value is rejected — an UNCHANGED legacy value is written back as-is, because the SPA sends the whole profile on every edit and rejecting it would make an old row uneditable over a field the operator never touched and, when hardware acceleration is not QSV, cannot see. Separately, the readrate pacing that PipelineBuilderBase hardcoded is now two nullable profile fields, ReadRate and ReadRateCatchup; null means unset and resolves to the values the pipeline used before they were configurable, so an untouched profile paces identically. -readrate_catchup stays ON by default and capability-gated in code — this makes it tunable, not optional. |
2026-08-26 | link |
api.from-lineup-clear-to-none |
POST /api/v1/channels/from-lineup (and the Auto-Tune per-channel advanced, which reuses the same DTO) distinguishes inherit from clear-to-none with a typed clear enum list on advanced. A field left null/omitted still inherits the template value (unchanged for every existing client); naming a field in clear forces it to none on the new channel even when the template sets one. Sending both a set value and a clear for the same field is a validation error. |
2026-07-21 | link |
api.healthcheck-remediation-dto |
Health-check remediation is server-declared {Kind, Target} metadata on an additive DTO field; the SPA renders/acts on it, it doesn't derive labels itself. |
2026-07-17 | link |
api.healthcheck-ttl-cache |
Health-check results are held in a 30s TTL cache inside HealthCheckService; a non-forced GET /api/v1/health returns the cached list, and ?refresh=true (or a forced internal caller) bypasses it to run fresh. |
2026-07-19 | link |
api.logs-sort-params |
GET /api/logs takes allow-listed sortField (timestamp|level) and sortDirection (asc|desc) query params, normalized (not rejected) on an unrecognized value. |
2026-07-11 | link |
api.mediatr-passthrough |
The REST API is thin controllers over existing MediatR handlers, with no new service/business-logic layer. | 2026-06 | link |
api.openapi-mirrors-runtime |
The generated OpenAPI spec is made to match the runtime Newtonsoft wire contract (via NewtonsoftSchemaNamingTransformer), not the reverse. |
2026-07-09 | link |
api.paged-count-matches-page-query |
A handler that returns a page plus a total count builds ONE IQueryable, applies every filter to it, and then derives BOTH the count and the page from that single object — int count = await query.CountAsync(ct) followed by query.Include(...).OrderBy(...).Skip(...).Take(...). Counting the DbSet directly, or re-stating the predicate in a second CountAsync(pred, ct), is the defect: the two expressions are then free to drift and nothing reports it. This is not a style preference — the drifted state is SILENT and shaped like working software. The page is correct, the count is wrong, and the client trusts the count: the SPA paginates on TotalCount, so 40 rows with 3 matching a search renders 4 pages of which 3 are permanently empty (#690), and an MCP agent paging to a completeness target reads a totalCount its own page can never reach (#758). Scope that harm honestly — of the six, only GetPagedRerunCollections, GetPagedMultiCollections and GetPagedPlayouts reach a controller today; GetPagedCollections, GetPagedSmartCollections and GetPagedProgramSchedules have no production caller (their REST routes use unpaged GetAll* queries), so they were latent, not live. Both named issues were ONE mechanism at six sites, of which the issues named two: GetPagedCollections, GetPagedMultiCollections, GetPagedRerunCollections, GetPagedSmartCollections, GetPagedPlayouts, GetPagedProgramSchedules. THE FILTER IS NOT ONLY THE SEARCH STRING — GetPagedPlayouts also applies Filter(p => p.Channel != null) to the page, and counting the DbSet missed that too; that clause is DEFENSIVE rather than a live defect, because Playout.ChannelId is non-nullable with DeleteBehavior.Cascade and both production connection strings set foreign keys=true, so the orphan state is unreachable while the FK holds. Three corollaries. (1) INCLUDES BELONG TO THE PAGE CHAIN, not to the shared filtered query: a COUNT does not materialize the graph, so .Include(...)/IncludeSelectionDetails() are appended after the count is taken, which keeps api.selection-projection-include-chain intact while leaving one predicate source. (2) A HANDLER WITH NO FILTER STILL TAKES THE SHAPE — GetPagedFillerPresets and GetPagedTraktLists take no Query parameter, so their DbSet counts were not WRONG, but leaving them counting one expression while paging another preserves exactly the drift this record is about for whoever adds the first filter. They derive both from one query too. (3) THE POPULATION IS DERIVED FROM THE SHAPE, NOT FROM THE GetPaged* NAME — three further count+page producers in ErsatzTV.Application/MediaCards (GetTelevisionSeasonCards, GetTelevisionEpisodeCards, GetMusicVideoCards) carry the same drift across a REPOSITORY boundary, where the count and the page are two interface methods rather than two expressions, so the structural fix cannot apply and they are pinned by a test instead (MediaCardsCountMatchesPageTests). GetSeasonCount now expands to the same Title+Year show set GetPagedSeasons pages; GetEpisodeCount and GetMusicVideoCount now count the METADATA table their pages are taken from, so a media item whose metadata row is missing no longer inflates the total. Their 1-based pageNumber is a separate defect against api.paging-zero-based and stays open in #832. |
2026-08-26 | link |
api.paging-zero-based |
pageNum is 0-based across the entire /api/v1 surface and every wrapper of it (MCP tool catalog, SPA hooks, docs); the page offset is always derived from the EFFECTIVE (bounded) pageSize, never the requested one, so a pageSize above an endpoint's cap narrows the page without widening the offset. The cap itself is per-endpoint (100 typical, 200 auto-tune members, 1000 search/all-items) and must not be documented as one number. A paging parameter description that omits or contradicts "0-based" is a defect. |
2026-07-25 | link |
api.parentid-drillin |
Media drill-in (season/episode/artist/music-video) is served by an optional parentId query param on library-browse, not dedicated per-kind child-listing endpoints. |
2026-07-07 | link |
api.playout-build-lock-409 |
Every id-keyed playout/channel mutation endpoint checks IEntityLocker.IsPlayoutLocked(id) and returns 409 Conflict while a build is in-flight, mirroring Blazor's disabled-buttons behavior; reset-all stays 202 and silently skips locked playouts. |
2026-07-10 | link |
api.postcommit-cancellation-none |
Once a mutation commits, its entire compensating side effect (enqueues, publishes, reindexes, cache refresh, and any post-commit lookup gating one of those) runs on CancellationToken.None so a late client disconnect can't half-abort an already-committed change. |
2026-07-11 | link |
api.put-replace-index-order |
PUT-replace-the-whole-list endpoints derive each item's Index from its request-array position, never a client-supplied field; alternate-schedule/playout-template rows are evaluated in Index order with the least-conditional row placed last as the catch-all default. |
2026-07 | link |
api.response-dtos |
New REST response DTOs live in ErsatzTV.Core/Api/<Domain>/*ResponseModel.cs with a file-scoped #nullable enable pragma; controllers never expose Application VM types directly. |
2026-07 | link |
api.schedule-item-flat-dto |
Schedule-item GET/POST/PUT use a flat, non-polymorphic ScheduleItemResponseModel (every subtype field promoted to a nullable top-level member) instead of the polymorphic Application VM hierarchy, with mutation field names matching ScheduleItemRequest 1:1 for a lossless round-trip. |
2026-07-10 | link |
api.scheduling-hardening |
Create/Replace handlers guard against null/whitespace name (IsNullOrWhiteSpace, not just Length) to prevent NRE-500s, template-item overlap validation compares by index (not record value-equality) to catch exact-duplicate items, and unreachable 404 ProducesResponseType attributes on create-only actions are trimmed. |
2026-07-13 | link |
api.search-allitems-paging |
GET /api/v1/search/all-items is paginated (capped page size, Totals field) to bound DoS exposure; the SPA add-all flow pages to completeness instead of relying on an unbounded response. |
2026-07-18 | link |
api.search-field-values-sources |
GET /api/v1/search/fields/{name}/values?q=&limit= returns distinct WHOLE values from the database for a narrow allow-list of catalog fields (never the Lucene term dictionary — analyzed TextFields store lowercased word tokens, e.g. "Science Fiction" → science/fiction, useless as a suggestion), 404 for an unknown field, a non-text field, or a text field with no distinct-value source (title, show_title only); limit clamped to [1, 50] (default 50). The FINAL filter, dedup and ordering applied to the response are ORDINAL (OrdinalIgnoreCase / StringComparer.Ordinal), never current-culture, because UseRequestLocalization makes the culture caller-controlled — scoped to the in-memory stages on purpose: a field sourced by a plain EF query is filtered and truncated by the DATABASE collation first (SQLite's LOWER() is ASCII-only), which ordinal semantics downstream cannot undo (ersatztv#668). A field whose values live in an EF primitive collection (one JSON array per row in a single column: SongMetadata.Artists, SongMetadata.AlbumArtists) is served, not 404'd, as bounded best-effort, and its rows are read by a keyset page carrying NO RESIDUAL predicate — SELECT Id, <col> AS Payload FROM SongMetadata WHERE Id > @AfterId ORDER BY Id LIMIT @Batch, no LIKE, no LOWER, not even IS NOT NULL. The cursor is itself a predicate, but a SEEKABLE one on the ordering key: it positions the scan and never discards a row. A RESIDUAL predicate discards rows the engine already produced, and LIMIT truncates only the survivors — so with one present it bounds the OUTPUT rather than the row count. All selectivity is in memory. The guarantee is scoped: at most 20,000 LOGICAL rows returned/materialized and at most 10 round trips (11 for artist) — NOT bounded physical work and NOT bounded bytes, because MySQL traverses deleted-but-unpurged index records and the TEXT/longtext payload width is unrestricted. The walk pages 2,000 rows at a time, stopping on the first of enough distinct matches, a short page, or the ceiling. |
2026-07-26 | link |
api.search-field-values-unicode-fold |
The EF-sourced facet fields (genre, show_genre, studio, director, writer, actor, tag, network, collection, video_codec, album, and artist's entity half) reach stored values whose prefix carries an uppercase non-ASCII character, on BOTH providers, with no row budget and no accepted loss. The defect was SQLite-only and ONE-SIDED: SQLite's LOWER() folds ASCII only (lower('Édith') is 'Édith' unchanged), so the predicate UNDER-matched, which no later stage can repair. MySQL was already correct — its LOWER() is Unicode-aware, so LOWER('Édith') really is 'édith' and the existing predicate reaches the row unaided. The fix is a SECOND, ADDITIVE query taken only when isSqlite && q contains a non-ASCII character: raw Dapper SQL SELECT DISTINCT <col> AS Value FROM <table> WHERE [<discriminator> AND] etv_upper(<col>) LIKE @Pattern ESCAPE '\' ORDER BY <col> LIMIT @Limit, where etv_upper is a SqliteConnection.CreateFunction scalar implementing ToUpperInvariant. Every other case — all-ASCII q, and MySQL for all q — runs today's EF query BYTE-IDENTICALLY. Keeping selectivity in SQL here is NOT the refuted family from api.search-field-values-sources: those four attempts bounded a walk around a predicate that could not be made correct over JSON escape text, whereas this is a correct fold on a plain column in an ordinary LIMITed query. It narrows that record's "Known limitation inherited, not introduced" clause; everything else it settles still holds. |
2026-07-27 | link |
api.search-paging-cap |
Search stays capped at 100 items per media kind; an overflowing kind's "See all" reuses library-browse paging instead of adding new API surface. | 2026-07-11 | link |
api.selection-projection-include-chain |
Every handler that projects an aggregate carrying a tagged-union selection loads it through ONE shared <Aggregate>QueryExtensions include chain — RerunCollectionQueryExtensions.IncludeSelectionDetails(), joining the existing ProgramScheduleItemQueryExtensions.IncludeScheduleItemDetails() — called by the paged-list handler and the by-id handler alike, so the two cannot drift. The media-item flattening switch is likewise ONE shared helper, MediaCollections.Mapper.ProjectMediaItemToViewModel, covering all ten selectable media types including RemoteStream, whose named projection is MediaItems.Mapper.ProjectToNamedViewModel (it cannot be an overload of ProjectToViewModel(RemoteStream), which already exists returning the unrelated RemoteStreamViewModel; C# will not overload on return type). That switch NEVER ends in _ => null: a null MediaItem is the legitimate not-a-media-item case, while an unrecognized non-null subtype keeps its id and takes a conspicuous [unsupported media type: X] name. Fail-soft is deliberate — throwing would fail an entire paged GET over one unreadable row. Finally, every metadata navigation inside MediaItems.Mapper is read through Optional(...).Flatten() and degrades to the "???" placeholder, because those projections are reached from handlers whose include chains differ and a bare x.Season.Show.ShowMetadata is a latent 500 on some other caller GET. |
2026-07-28 | link |
api.versioning-v1 |
The entire /api surface is versioned to /api/v1 uniformly (no unversioned corner); legacy unversioned callers are rewritten in-pipeline (not redirected) with Deprecation/Link/Sunset headers, and post-freeze /api/v1 is additive-only — a breaking change requires /api/v2. |
2026-07-13 | link |
blazor.rollback-tag |
The commit immediately preceding the Blazor-removal merge is tagged blazor-final (not a v* tag, so it doesn't trigger a prod release build) as the documented rollback/restore path. |
2026-07-11 | link |
blazor.ui-removed |
The legacy Blazor Server UI (Pages/, Shared/, ViewModels/, Validators/, MudBlazor + 8 other packages, Blazor Startup wiring) is fully deleted now that the SPA has parity; the legacy MapWhen branch is kept only for controllers/docs/OpenAPI/LegacyUiRedirects, and the catch-all fallback 302s any unmatched non-api/artwork/docs/openapi path to /app. |
2026-07-11 | link |
channel.origin-marker |
A new Channel.Origin (ChannelOrigin enum — Unknown/UserCreated/AutoTuned) records how a channel row was created and is stamped exactly once at insert (AutoTuned in CreateChannelFromLineupHandler, UserCreated in CreateChannelHandler), and is never mutated on a later edit. It is surfaced as a raw origin field on ChannelResponseModel; the SPA badges only AutoTuned. Rows predating the column read Unknown — provenance is not back-filled. |
2026-07-23 | link |
ci.actions-credential-scoping |
Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret REGISTRY_PASSWORD is a personal access token scoped write:package + read:repository — never an account PASSWORD. This matters because Gitea has NO status token scope: POST /repos/{o}/{r}/statuses/{sha} is gated by reqRepoWriter(unit.TypeCode), so ANY credential that can write the repository can forge review-verdict/h10, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: scripts/ci-detect-already-validated.sh only GETs. permissions: on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on set-verdict-status (code: write posted the probe status; code: read 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is permissive, which is what makes the five code: read declarations effective TODAY rather than only after a flip. NOT established: what a declared code: write does under a RESTRICTED default. GitHub semantics let permissions: only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and review-verdict/h10 would stop being writable — the exact catastrophic case. The probe ran under permissive and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a permissions: key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (/api/v1/settings/actions 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to permissive on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for ci-image.yml in #744 (ci.toolchain-image-publish-is-a-dispatch) — deliberately NOT bundled here, because until it also removed the file from ci-image-pin's expected, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: actions/checkout persists whatever the job token can do into .git/config unless persist-credentials: false is set — read-only everywhere since #748 declared permissions: on all six workflows — and all 16 of this repo's checkouts now set it — 15 in #746 and ci-image.yml's in #744, once ci.toolchain-image-publish-is-a-dispatch removed the two mechanical reasons it was excluded; the convention is held with no exemption list by scripts/tests/test_workflow_persist_credentials.py (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The workflow_dispatch half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (ci.workflow-dispatch-ref-unrestricted) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because docker-build.yml's head-resolved pull_request: runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the v* tag push, not dispatch, are the live residuals (#885). |
2026-08-05 | link |
ci.batch-pushes-no-cancel-route |
Hold review fixes, doc corrections and format fixes locally and push once — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes. | 2026-07-21 | link |
ci.build-once-rejected |
CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead. | 2026-07-18 | link |
ci.cancelled-is-not-a-verdict |
Treat a cancelled conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. THE COMBINED COMMIT-STATUS ENDPOINT CANNOT EXPRESS THIS: GET /repos/{o}/{r}/commits/{sha}/status has states success/failure/pending/error and NO cancelled, so it reports a cancelled job as failure. Anything polling that endpoint — which is what a CI monitor naturally polls, because it is the per-sha view the merge gate reads — must resolve the job-level conclusion via actions/runs/{id}/jobs before reporting a red. |
2026-07-21 | link |
ci.decisions-edit-trailer |
The body-diff exemption is armed by an affirmative Decisions-Edit: git trailer (yes/true/1, case-insensitive, read with unfold) on some NON-MERGE commit in the PR's merge-base range — never by a substring search over the message text. A non-affirmative value (no) does not arm it, the retired [decisions-edit] substring arms nothing (the validator emits a ::warning:: nudge when it sees one without a trailer), and a git error leaves the guard ON. |
2026-07-25 | link |
ci.decisions-lifecycle-flake |
When decisions lifecycle is the only red job, do not investigate and do not create a new run to clear it — no rebase, no --amend, no no-op push; the operator reruns that single job from the Gitea UI. |
2026-07-21 | link |
ci.docs-only-detect-shallow-safe |
The docs-only detect script must diff against FETCH_HEAD (always resolves after git fetch, even shallow) using a two-dot tree diff — not origin/<base> with three-dot — because a fetch-depth: 1 shallow clone has no remote-tracking ref and no merge-base, which silently fails the original detect into docs_only=false (full matrix, no functional error). A CI-behavior change must be verified by measuring the effect (job durations), not just a green check. |
2026-07-17 | link |
ci.docs-only-skip-steps |
A docs-only change must still run every required job (test, migrations) so their commit-status contexts always report; each heavy job runs scripts/ci-detect-docs-only.sh first and gates its real STEPS on if: steps.detect.outputs.docs_only != 'true', never if:-skips the whole job (an if:-skipped job reports skipped, not success, which branch protection may never unblock on). Detection biases toward running more on any doubt. |
2026-07-17 | link |
ci.exemption-provenance |
The three inputs the exemption decision rests on must each be bound to something the judged PR cannot mutate. (1) BASE — scripts/pr-changed-files.sh takes the expected base BRANCH as a REQUIRED 5th argument and re-reads it before and after paging, because /pulls/{n}/files diffs against the PR's live base and retargeting moves the answer without moving the head sha; the workflow passes github.event.pull_request.base.ref from the pull_request_target payload, which a retarget cannot rewrite. (2) BOT EXEMPTION — an author match is necessary but never sufficient: pull_request.user.login is the PR's immutable CREATOR while its head is not, so the exemption additionally requires EVERY changed path to be a dependency manifest (Directory.Packages.props or .config/dotnet-tools.json, and ONLY those — the npm manifests are excluded because package.json scripts are executed by CI). (3) INHERITED SUCCESS — the never-overwrite short-circuit fires for a success only when it is POSITIVELY identified as an APPROVED REVIEWER's verdict for THIS base, meaning a .creator.login that is a member of the workflow's H10_REVIEWERS allow-list (an existing failure takes the weaker ATTRIBUTABLE test and needs no membership, because inheriting a rejection can only withhold an exemption while re-deriving one can turn it green on an exempt PR) — non-null was too weak, since any account's credential satisfies it and RENOVATE_TOKEN is one that cannot be scoped down (#742) — AND a Review-verdict: description AND, when that description records a base ((base: …), release.verdict-status-check), a base matching the PR's — tested by requiring the description to END with the exact literal (base: <base>) and to contain exactly ONE such marker, never by extracting a value (see below); a present-but-different base is rejected, an absent one is not, since verdicts predating that convention carry none; every other shape, including any unrecognised one, is re-derived rather than trusted. The bot and docs-only exemptions are evaluated as INDEPENDENT predicates and the decision made afterwards, never as an elif chain. edited is in the workflow's types: so a retarget reclassifies — which gives DETECTION, not atomicity: status writes are not serialized, so a stale run can still post over a fresher one. That residual is now FENCED rather than merely tracked (NARROWED, not resolved — a retarget between the fence's final count and the POST still leaves a PERMANENT forged green, #849) — the job refuses to write at all if the PR's timeline retarget COUNT moved while it was classifying (ci.verdict-write-retarget-fence, #706) — leaving TWO windows, not one: the sub-round-trip window that no API without compare-and-set can close, AND the post-final-count/pre-POST window, which is not sub-round-trip and yields a PERMANENT green because the successor can consume the edited event and exit before the stale run posts last (#849). The PROTECTED path list additionally covers CLAUDE.md and AGENTS.md (#751) — they are not prose but the documents DEFINING the completion protocol, the merge-consent convention and the H10 rule, so protecting .claude/ while the file specifying what it enforces stayed docs-only-exempt was the same self-exemption one directory over; driving the real classify body with a lone CLAUDE.md change produced an exemption success. README.md is deliberately not listed. It also covers .codex/ (#711), which mirrors .claude/hooks/ byte for byte including the merge-consent hook — latent while that directory is untracked, live the moment it is tracked; the list stays ENUMERATIVE rather than derived, because a derived rule would have to be evaluated against the very file list being classified. Reading the CURRENT status for input (3) must tolerate statuses: null: GET /commits/{sha}/status serialises a nil slice as null, not [], on a head with no statuses yet, and an array-only gate made read_existing_verdict exit 1 and post nothing at all (#751, ci.workflow-run-body-no-expressions) — null is accepted only when total_count is 0, so a body that merely lost its array is still refused. Path predicates are evaluated by COUNTING with grep -c, never | grep -q (SIGPIPE inversion) and never a here-string (temp-space failure) — see ci.grep-q-pipefail-inversion. The WRITER is bound to the same allow-list rather than trusted to match it (#845): scripts/post-review-verdict.sh reads its own status back, identifies that write by state and description, and refuses before writing the comment unless .creator.login is a member — so a writer credential that is not a reviewer fails at the terminal that made the mistake instead of stalling the PR one event later. The list is DERIVED from the literal in this workflow by scripts/lib/h10-reviewers.sh, never restated: one declaration, not two plus a parity test. |
2026-07-29 | link |
ci.fetch-depth-never-grafts-a-complete-clone |
Any git fetch in CI tooling passes --depth only when git rev-parse --is-shallow-repository already answers true, and passes none on any other answer including an unreadable one. git fetch --depth=N GRAFTS a complete clone shallow: it writes .git/shallow and cuts history at N even though every object is already present, so a depth chosen for one consumer silently breaks every other consumer that checks out fetch-depth: 0. A shared script whose consumers disagree on checkout depth must ask the repository rather than assume. And whatever the fetch feeds — a version, a diff, a base revision — must be ASSERTED rather than defaulted: a fallback that cannot fail converts the graft into a plausible wrong answer that never reddens. |
2026-08-29 | link |
ci.format-gate-folder-mode |
The blocking format CI job (and matching pre-commit hook) runs dotnet format whitespace . --folder --include <files> instead of loading the full MSBuild/Roslyn solution, cutting the gate from ~480s to ~0.5s with unchanged whitespace/charset coverage. |
2026-07-19 | link |
ci.functional-e2e-harness |
The functional-e2e CI job boots the PR's own code from source via dotnet run (scripts/e2e-local.sh) and runs deterministic assertions (scripts/e2e-functional.sh) as an advisory (non-blocking) job, not a build dependency or required check. Originally curl-only; since #445 the same job carries a second, headless-browser step for the contracts curl cannot express — see ci.ui-e2e-harness. |
2026-07-16 | link |
ci.gate-trigger-base-resolved |
The workflow that writes the branch-protection-required review-verdict/h10 status triggers on pull_request_target with branches: [main], never on plain pull_request. Gitea resolves a pull_request workflow DEFINITION from the PR's own head commit, so under that trigger a PR editing .gitea/workflows/review-verdict.yml ran its own rewritten copy and could post h10=success for itself; pull_request_target resolves the definition from the base instead. The branches: [main] filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch's gate. pull_request_target is safe HERE only because this job never checks out or executes head-supplied code — it checks out base.sha and runs only that tree's scripts (ci.shared-pr-file-enumeration); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class. Gitea injects GITEA_TOKEN into EVERY job and it USED to be write-capable everywhere, so any ref-resolved workflow could forge review-verdict/h10; since #748 (2026-08-27) all six workflows here declare permissions: — five code: read, and review-verdict.yml's gate job code: write, deliberately, because it IS the gate (the five rest on the TOP-LEVEL form, which is INFERRED rather than probed — the gate job's JOB-LEVEL form is the one measured; see ci.actions-credential-scoping). That narrows the INJECTED token and only that: deliberately NO "only X can post a status" summary is stated here, because any such summary generalizes from the injected token, which permissions: scopes, to every credential a job can reach, which it does not touch — RENOVATE_TOKEN is in the same secret store and is referenced by renovate.yml, one of the five holding code: read. ci-image.yml was the last holdout — its unfiltered push: made it the worst-placed of the six — and #744 closed that trigger route (ci.toolchain-image-publish-is-a-dispatch) while #748 scoped its token. So the route is NARROWED, not closed, and the SHAPE is open — a newly added workflow declaring write, or omitting a declaration and inheriting the owner default, is a route again, which is why test_pr_changed_files.py asserts every tracked workflow declares a block. And a collaborator's own API token still can, since branch protection binds the context and not its issuer. The credential half is now RESOLVED in ci.actions-credential-scoping (#697): CI's registry secret was the ADMIN account's basic auth and is now a PAT that cannot post a status, which removes the ADMIN escalation and that credential's route (an ALLOW-LISTED user credential's forgery carries a matching creator and is inherited as a verdict; an Actions job's carries creator: null and is re-derived, as since #742 is every account outside H10_REVIEWERS — but do NOT read that asymmetry as protection: re-derivation fires only on the trigger's types, and posting a status is not one of them, so a POST timed after the last PR event simply stands). It does not remove EVERY route: RENOVATE_TOKEN is a write:repository bot PAT in the same secret store, reachable by any PR-added workflow. The owner-level Restricted default (server-management#714) remains a separate OPEN lever — its effect on a declared code: write is unmeasured, and the version half of that condition has been satisfied since the 1.25.4 -> 1.27.1 upgrade on 2026-08-05, so it is not something to wait on a Gitea release for. A collaborator's own token remains unfixable; the exemption path has its own separate defects in #698. |
2026-07-28 | link |
ci.gitea-milestone-filter-noop |
Never filter issues with the server-side ?milestones=<name> parameter — fetch all open issues once and filter LOCALLY on each issue's .milestone.title. |
2026-07-21 | link |
ci.grep-q-pipefail-inversion |
In any script running under set -o pipefail, a security or classification predicate of the form producer | grep -q… is FORBIDDEN: grep -q exits at its first match, the producer then takes SIGPIPE and exits 141 once the data exceeds the pipe buffer (~64K), so pipefail reports the pipeline as FAILED even though grep MATCHED — inverting the predicate exactly when the input is large. A here-string (grep -q… <<< "$data") is ALSO forbidden: bash materialises a large here-string via temporary storage, so it fails when temp space is full or unwritable, and inside an if/! that failure flips the predicate the same way. COUNT instead — n=$(printf '%s\n' "$data" | grep -cE "$re") — because grep -c drains stdin (no early exit, no SIGPIPE) over an ordinary pipe (no temp file). Read grep's status honestly: exit 1 means a zero count and is a legitimate answer, anything >1 is a real error. Evaluate the counts ONCE at TOP LEVEL, never inline inside an if/elif condition: inside $( ) an exit leaves only the subshell and set -e does not fire, so an error silently reads as "no match". Validate that each result is numeric and fail closed if not. This applies to both the enforced gate .gitea/workflows/review-verdict.yml and the advisory hook .claude/hooks/pretooluse-merge-consent.sh. |
2026-07-29 | link |
ci.image-build-delegates-the-spa-suite |
docker/Dockerfile runs no vitest suite. Its web-build stage lints, typechecks and BUILDS the SPA; the suite runs once, unfiltered, in docker-build.yml's test job on a real checkout, and build carries needs: [test, migrations, scan] so no image is published past a red suite. THAT EDGE IS NOW THE ONLY LAYER, so the guard checks it is real and not merely present — and it does so by PINNING TEXT rather than parsing it. The commands each SPA-carrying Dockerfile stage runs, and the gating step's run: body and if:, are compared as strings against a declared pin; the step and its job must carry no continue-on-error in any spelling, the job no job-level if:, and the publish step must keep its own docs_only gate. A guard that asks what a command MEANS was wrong nine times in three review rounds. A pin is immune to a different SPELLING of the command, which is that whole class; it is not immune to the same text meaning something else, so the routes to that are pinned or refused too: working-directory and the step's own shell:, the workflow defaults.run.shell and the job-level defaults overriding it, a stage SHELL, web/package.json's script map (pinned WHOLE — selecting on the literal vitest missed npm run test, npm t and the prebuild/preinstall lifecycle hooks), web/vite.config.ts's test: block, which decides what the suite collects, and the ABSENCE of any vitest.config.* — vitest resolves its own config in preference to vite.config.*, so pinning one file is worthless while a second can outrank it. The gating job's container: image is left to ci.image-pin-population rather than guarded twice. What is unmodelled is a LIST, not an "only": an ENV changing what a pinned RUN resolves, the plugin BODIES, and a publish through another action. ELEVEN routes is a running total, not a proof — written as five, six, seven and eight, each wrong when checked. They share one shape, which is the transferable part: A PIN ASSUMES IT IS PINNING THE ARTIFACT THAT STILL DECIDES, and every route found so far is authority moving to another file, another occurrence, another workflow, or a hook the pinned command invokes. Do NOT restore a filtered in-image run by naming the specs that cannot run there: that list is a population nothing derives, and the red it produces when it goes stale is unreachable on a PR — Build & push image (amd64) is if: github.event_name != 'pull_request' — so it lands on main and on the v* tag path, where it fails the release cut. Held in both directions by scripts/tests/test_image_build_delegates_the_spa_suite.py. |
2026-08-30 | link |
ci.infra-shaped-red-under-load |
When a job dies inside a setup/cache step before your code compiles, check the runner host's load before diagnosing the diff, and never file a CI bug off one sample under pressure. | 2026-07-21 | link |
ci.jq-version-contract |
Every shell gate that shells out to jq is authored to the jq 1.6-compatible subset, because the CI runner ships jq 1.6 while every developer Mac ships 1.8.x. scripts/jq-preflight.sh (no args) prints the parsed version and asserts a floor of 1.6 in every gate job's log; scripts/jq-preflight.sh --expect 1.6 additionally pins and fails loudly, but ONLY in the script-tests job. review-verdict.yml never pins — it writes the branch-protection-required review-verdict/h10 status, so a hard pin there would turn any jq bump into a repo-wide merge deadlock. |
2026-07-26 | link |
ci.killed-job-triage |
Never trust a job's conclusion field alone — read the log tail and require an ❌ Failure - Main … marker before treating a red as a real failure. |
2026-07-21 | link |
ci.monitor-armed-at-pr-open |
Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | link |
ci.no-host-health-gating |
Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | link |
ci.peak-anon-measurement |
The test job's headline memory figure is a sampled high-water mark of cgroup anon, produced by scripts/ci-peak-anon.sh; memory.peak and the end-of-job anon/file split are kept only as a cache-inflated reference. |
2026-07-19 | link |
ci.python-lint-ruff-config-committed |
The repo commits ruff.toml, and the script-tests job runs ruff check + ruff format --check under a PINNED ruff over an EXPLICIT population from git ls-files, never ruff check .. Never rely on ~/.config/ruff/ruff.toml, and never add a lint rule to the config without making the tree clean against it in the same PR. |
2026-08-21 | link |
ci.required-job-step-execution-markers |
A step the runner declines to interpolate is DROPPED and the job still concludes success (ci.workflow-run-body-no-expressions). In review-verdict.yml that is fail-CLOSED — the required status is absent and the merge is blocked. In docker-build.yml's test and migrations it is fail-OPEN: those are the other two required contexts on main, so the check reports green having done no work. So in those two jobs every run: step that is not continue-on-error: true calls "$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark <key> as its FIRST act, and the job's LAST step calls ci-step-ran.sh assert --always <keys> --gated <keys>, which fails the job when an expected key was never recorded. PER STEP, not per job: a marker written by the first step only proves the job started, while the drop that costs something is Test or the migration replay. The guard carries NO if: — the default success() is the wanted condition, because a genuine failure in an early step legitimately skips every later one and an always() guard would announce a false "these steps never executed" on every ordinary red build; the invariant that makes the omission safe is that the guard is skipped only when an earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every path to a green job runs the guard. Separately and independently, no ${{ OPENER may appear in any run: body of those two jobs OR of build — the drop mechanism requires the opener, so banning it makes the class unreachable rather than merely caught, and an UNCLOSED opener triggers the same rewrite as a well-formed pair. Pass values in through the step's env:, which is interpolated per value. The two halves have DIFFERENT scopes on purpose: markers cover the required pair, while the ban also covers build, whose Smoke + IPTV E2E step runs AFTER the image is pushed, so a drop there publishes a release candidate that was never booted and that DeployStack jazz-media then promotes. functional-e2e is delimiter-free but deliberately excluded (advisory by declaration), and api-docs/format keep one github.base_ref each and gate nothing that ships. The ban is enforced on the RELEASE PATH itself, not only in review (#767): a scan job runs the PyYAML-based ban test and build lists it in needs:, so a delimiter means build never runs and no image is published. A guard STEP inside build was tried first and is wrong — a step cannot protect the job it publishes from, and "my body has no opener so I cannot be dropped" is circular when only the PR-only test enforces that. The pytest in script-tests remains, but it is on: pull_request and not a required context, so it alone left the tag path unchecked. |
2026-08-10 | link |
ci.root-screenshot-guard |
The Husky pre-commit hook refuses a staged root-level *.png (belt-and-suspenders with the .gitignore rule); nested *.png real assets are unaffected. |
2026-07-12 | link |
ci.runner-placement |
No persistent Roslyn compiler server survives a CI build (UseSharedCompilation=false etc., runner env + Dockerfile ENV); every services: container gets its own explicit --memory/--memory-swap/--cpus cap (it does not inherit the job container's). |
2026-07-17 | link |
ci.script-tests-job |
The scripts/tests/ pytest suite runs on every PR as a dedicated script-tests job in pr-checks.yml (runs-on: small, setup-python + pip install pytest pyyaml, PYTHONPATH=. python3 -m pytest scripts/tests -q; since #780 it also runs a pinned ruff over a git ls-files population first), unconditionally rather than behind a scripts/** path filter, and never as a step inside decisions-guard — a job whose reds a standing rule instructs sessions to ignore must never host a gate whose reds are real. Any new CI gate must be reachable by a failure that is unambiguously attributable to it. |
2026-07-26 | link |
ci.shared-pr-file-enumeration |
A PR's complete set of changed file paths is computed by exactly one implementation, scripts/pr-changed-files.sh, called by both .claude/hooks/pretooluse-merge-consent.sh (advisory — a failure falls through to a human prompt) and .gitea/workflows/review-verdict.yml (enforced — a failure must fail closed, because a match here posts the branch-protection-required review-verdict/h10 status with nobody in the loop). The script owns exhaustiveness (pagination, rename/path validation, head-sha and base-ref MOVEMENT DETECTION — one-way, never a binding: an A->B->A alias on either axis passes, #664/#803 — see ci.exemption-provenance — and base-TIP binding, #707: the ref answers "did this PR RETARGET", the tip answers "did the base ADVANCE mid-enumeration", and only the second can see /pulls/{n}/files recomputing each offset-paged page against a moved base and dropping a path out of an already-consumed range; both ends of the window are read and compared, and an advance BEFORE the window is deliberately not an error, or ordinary churn on main would fail every open PR) and returns exit 0 only for a verified-complete list; it does NOT classify paths — each caller keeps its own docs-only allow-list, and the two allow-lists differ on purpose and stay separate. |
2026-07-26 | link |
ci.small-lane-git-only |
runs-on: small is defined by what a job does (git-only), not its usual runtime; the two docker build jobs (docker-build.yml, ci-image.yml) move to ubuntu-latest because their worst-case memory, not median runtime, was pinning the small lane's per-slot cap. |
2026-07-20 | link |
ci.toolchain-image-publish-is-a-dispatch |
A push trigger reachable from any ref other than main — that is branches: AND tags:, judged by the ref class it admits and never by which keyword is present — executes ref-supplied YAML, because Gitea resolves a push workflow's definition from the pushed ref — so ci-image.yml is branches: [main], and publishing a toolchain image from a feature branch is a deliberate workflow_dispatch on that branch rather than a side effect of pushing. Be precise about what a branches: filter buys: it is loaded from the pushed ref like the rest of the file, so a branch that deletes it re-enables the route — this removes the DRIVE-BY case, and is not a boundary against a writer who intends to run their own YAML. The self-referencing trigger path .gitea/workflows/ci-image.yml came out of BOTH its own paths: and ci-image-pin's expected in the same change — a DECIDED tradeoff, not a necessity: keeping it is workable via the branch dispatch, but prices every edit to that file, comments included, at a ~2GB publish plus a five-pin bump, redone after every rebase. The cost is stated, not assumed away — a change to HOW the image is built that lives only in ci-image.yml no longer republishes, and the ONLY remedy is to make it alongside a docker/ci/** edit: publishing after the merge and then pinning cannot work, because expected is the last docker/ci commit and would reject that pin. This closes the push route INTO THIS FILE, not the class: docker-build.yml remains reachable from an arbitrary ref by a v* tag push and by pull_request, and four workflows carry an unrestricted workflow_dispatch. The DISPATCH THIRD of that is settled — #853 probed it and ACCEPTED it (ci.workflow-dispatch-ref-unrestricted): no ref restriction exists at Gitea 1.27.1, and restricting it would close nothing anyway, because the head-resolved pull_request: route runs attacker-authored YAML that reaches every secret in the store. The v* tag push and pull_request: rows are NOT settled and remain open in #885. Do not re-derive any of this. |
2026-08-27 | link |
ci.ui-e2e-harness |
The UI-interactive E2E flows run as headless Playwright specs (web/e2e/*.spec.ts, driven by scripts/e2e-ui.sh) in a second step of the existing advisory functional-e2e job, never their own job; the browser is chromium-headless-shell baked into the CI toolchain image (docker/ci/Dockerfile, PLAYWRIGHT_VERSION kept equal to web/package.json's EXACT @playwright/test pin), never installed per run; specs are serial with retries: 0 and assert only contracts the curl harness structurally cannot reach. |
2026-07-25 | link |
ci.verdict-unverified-write-sentinel |
The review-verdict/h10 job runs its post-write race check after EVERY status write, not only an exemption success, and where no mark exists to run it against, the write ITSELF becomes the sentinel — so every status this job writes is either verified against the history or marked as unverifiable: a GENERIC pending masks a rejection landing in its own write window exactly as a success does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run's high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, UNVERIFIED_DESC, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into success); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages /statuses/{sha} IN FULL and, ONLY IF that history contains the sentinel's own row, either finds a Review-verdict: row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no H10_REVIEWERS membership, no (base: …) match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk CONTAINING THE CURRENT SENTINEL ROW — matched by the id the combined read reported, not merely by a row with the same description — may clear it. Description alone is satisfied by an OLDER identical sentinel, which is exactly what a fixed point produces: with an earlier sentinel and a buried verdict below the current one, a read carrying only the earlier row satisfies the test, the sentinel clears, and the verdict ends up below the fresh mark. Where the server omits id there is nothing to match on and the check degrades to the description, which is the pre-existing behaviour rather than a new hole. The witness is not optional decoration: ex_unverified means the combined endpoint just returned that row, and /statuses/{sha} keeps one row per POST, so a complete history WITHOUT it — an empty one included — contradicts a write that demonstrably happened. page_statuses accepts an empty page 1 as complete (correct for a first-run mark), which is what made that shape reachable, and clearing on it exempted a head whose sentinel may have been sitting on a rejection. This is NOT the withdrawn currency witness: that asked whether ANY row sat above the mark, which an unrelated row satisfied, and it made one anomaly permanent — this asks for a SPECIFIC row already known to exist. Failing it costs one run in the transient case; it is NOT bounded in general, because a history past the 20-page cap can never be walked completely, so such a head needs a human verdict and no later run will clear it. When NO high-water mark can be established the write is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. EVERY re-derivable state is downgraded there, not only success: an earlier draft restricted it to the exemption on the grounds that a sticky generic pending "withholds nothing, since an unreviewed PR is blocked already", which analysed the wrong PR — the damaging case is one that IS exemptible and got the generic pending only from a transient enumeration failure, whose unmarked description the next run re-derives into the exemption with the human row below its own mark. $REPAIR_DESC is the one exemption, being the stronger fact and not re-derivable. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green ci.verdict-write-retarget-fence recorded as its residual: a retarget landing after the final pre-write count leaves a stale success with the edited event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale success exists, and a machine-written success is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with success means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, every path that cannot establish what the head carries REPLACES the unknown state instead of merely declining to write — the combined read (retried once first), all four page-2 completeness refusals, and the fence branch that cannot trust its retarget count while holding a derived success. That last one is reached only AFTER the classification declined to inherit the row the head carries, so posting nothing left the declined row current; its message used to say the context "stays absent", which is true only of a head that had none. The two OBSERVED-mutation arms take the same decision for the same reason, scoped to a head that actually carries a declined row: they still refuse to post their CLASSIFICATION, which was computed against a base or head the PR may no longer have, but a row this run declined must not stay authoritative for the whole window until a successor finishes — and for a PR's FIRST push no successor is queued at all. Every element and every consumed field of the combined response is TYPE-CHECKED before extraction, and a schema failure routes to the same replacement rather than dying: .statuses being an array was checked and its ELEMENTS were not, so one scalar made select(.context == $c) hard-error and set -e took the step down before any path could mark the head. The path-predicate failure replaces too. And the write helper RETURNS a status: its first version ended the failure arm with a successful echo, so it reported 0 after both POSTs failed and the fence caller exited 0 as though the head had been marked. Declining protects a real verdict and leaves a FORGED one — an off-list success is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run's reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. The page-2 refusals were briefly excluded on the reasoning that the probe fires when NO row for this context was on page 1, so there is no green of any provenance to leave standing — self-contradictory, since the ONLY reason page 2 is read is that the row MAY be beyond page 1. WRITING THE SENTINEL AND FAILING THE JOB ARE SEPARATE DECISIONS: the read refusals were already non-zero exits on main and stay red, while the fence branch exited 0 there and still does, because an unreadable timeline is an ordinary hiccup and reddening every one of them is noise this file elsewhere refuses to add. The repair also has a FLOOR — it may never write a description weaker than the one this run decided, or a transient post-write read rewrites a correct $REPAIR_DESC carry-forward with the machine-clearable sentinel — and it is skipped entirely when it would rewrite what is already there. |
2026-08-29 | link |
ci.verdict-write-retarget-fence |
The review-verdict/h10 job counts BOTH change_target_branch AND pull_push events on the PR's issue timeline at run start and again immediately before its POST, and does not post its CLASSIFICATION if EITHER count moved. Since #849 it does not merely abstain either: when the head carries a row this run did not inherit — and that row is neither the repair sentinel nor an allow-listed reviewer's verdict — the arm REPLACES it with the unverified-write sentinel, because abstention is a handoff only when there is nothing to hand off (ci.verdict-unverified-write-sentinel). The COUNT is the key on both axes because the underlying VALUE is ABA-vulnerable — main -> S -> main reads main at both ends, which is how #698 route 1 obtained a forged exemption, and a force-push H1 -> H2 -> H1 leaves .head.sha equal at both ends while the middle pages of scripts/pr-changed-files.sh's enumeration came from H2 (#803/#664) — while an event count is monotonic and cannot alias. ONE walk certifies BOTH counts, so an unreadable page abandons both; the two tallies are separate so the diagnostic names the axis that actually moved. Every push is counted and is_force_push is deliberately NOT read: an ordinary push also invalidates a mid-flight enumeration, and an H1 -> H2 -> H1 restoration can have its second push non-forced when H1 is an ancestor. The head fence does NOT abstain on its own triggering push — established FROM THE v1.27.1 SOURCE, since that would be a permanent stall rather than a fence: the push comment is created BEFORE the synchronize notification is emitted, so a run always sees its own causative event, and retries add no new event. The 26-102s margin measured across 20 triggered pairs on PRs #802/#834/#761 corroborates it and is a lower bound (the job runs a checkout first; 69s end to end on run 2385) — it is NOT the basis of the claim, which an earlier draft said it was. This NARROWS the residual rather than resolving it, and what CLOSED the permanent case is a separate mechanism recorded at ci.verdict-unverified-write-sentinel: the retarget count is re-taken AFTER the POST, so a retarget between the final pre-write count and the POST — which used to leave a PERMANENT forged green, the successor having consumed the edited event and exited before the stale run posted last — is now caught by the writing run itself, and one landing after the re-check necessarily queues a successor that starts with the stale success already visible and re-derivable (corrected 2026-08-27, closed 2026-08-29, #849). Abstaining is a handoff, not a stall, and that is the property the design rests on — for a run that ABSTAINS; it says nothing about one that already passed its final count and then posts (see the residual, corrected #849): every retarget fires edited, which is in this workflow's types:, so the event that makes a run abstain has already queued a successor whose window opens after it; the induction terminates when retargeting stops — but ONLY over runs that ABSTAIN. A run that already passed its final count is outside it: it writes whenever it gets there, so the run that writes LAST is not necessarily the one that classified last. That was the permanent residual until #849 gave the writing run a post-POST re-count of its own (ci.verdict-unverified-write-sentinel), which puts such a run back inside the induction — it either withdraws its own stale write or leaves a green a guaranteed successor re-derives. updated_at was REJECTED as the key because it also moves for comments and labels, which fire none of this workflow's types: — a run could abstain with no successor coming, which is a real stall. The count is trusted only when the walk read EVERY page up to its 20-page cap and the LAST page came back empty — an empty page BEFORE the cap is SKIPPED rather than read as exhaustion, since Gitea pages this endpoint before it filters and a fully-filtered page is byte-identical to the end of the list (#870) — which NARROWS that defeat about 10x rather than closing it — the 50-row filtered block is unchanged, but the timeline it must sit in grows from ~100 rows to over 1000 — the page-20 terminator still being trusted for the same unprovable reason; an untrusted count (unreadable page, non-array body, non-numeric length, empty FIRST page, a cap reached on a non-empty page) blocks the exemption success ONLY and still lets pending through, because pending blocks the merge immediately while withholding it would strand ordinary PRs whenever the timeline is unreadable — the right trade, but NOT a free one ("for no safety gain" retracted 2026-08-27): a GENERIC pending masks a rejection landing in its own write window just as a success does. What made that DURABLE — post-write verification skipping it, so a later run re-derived it into an exemption success — is closed since 2026-08-29: the check now runs after EVERY write (ci.verdict-unverified-write-sentinel), so such a write is repaired to the sticky repair sentinel and cannot be re-derived. The masking itself is still a cost, and a write the mark cannot cover is still unverified; that is what the second sentinel is for. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting ANY status — every write since #849, not only an exemption success — the job re-reads /statuses/{sha} IN FULL (PAGED since #763) and, if a human Review-verdict: row appeared with an id ABOVE a high-water mark taken just before the POST, overwrites its own status with pending and logs an error. The repair is pending, NEVER a copy of the human's state, since re-posting their failure under the machine credential would attribute a human verdict to the job; its description is a SENTINEL that the classification refuses to grant an exemption over AND re-writes verbatim on every later run, so the block is a FIXED POINT rather than decaying — writing the generic pending description there instead erases the marker and the exemption simply returns one event later. The mark is captured BEFORE the last-moment re-read, not merely before the POST — a later mark leaves a multi-round-trip blind gap in which a verdict is neither seen by the re-read nor repaired afterwards. The id comparison is load-bearing: a mere presence test would fire forever on a base-mismatched verdict that read_existing_verdict deliberately declines to honour, deadlocking that PR's exemption permanently. Finally, a run whose last-moment re-read finds a sentinel it did not see at its FIRST read ABSTAINS instead of posting: that can only mean an overlapping run repaired a raced verdict mid-flight, and this run's success — frozen at classification time, with the human row below its own mark, so neither the fence nor the post-write check would catch it — would otherwise bury the rejection. That is the one path in this design that failed toward SUCCESS rather than pending. The post-write check counts TWO row shapes above the mark, not one — a human Review-verdict: row AND a machine sentinel — because with two overlapping runs the human row can sit BELOW the second run's mark while the first masks it and only then writes the sentinel, leaving the second to post its own success on top; counting the sentinel converges both runs on the fixed point instead. BOTH /statuses/{sha} reads PAGE to a validated EMPTY page since #763 — [] on this endpoint, a THIRD terminator shape distinct from the timeline's bare null and the combined endpoint's {"statuses": null}, so each terminator is MEASURED per endpoint — never terminating on a SHORT page, retrying each page once, under a 20-page cap that validates at most 950 rows (the 20th request must be the empty terminator); correctness does not depend on the measured cap of 50, because any page size pages correctly. WHAT THE PAGING BUYS IS NOT WHAT #763 CLAIMED: under the DESC default page 1 already held the true maximum id AND every row newer than the mark, so a single-page read missed a raced verdict only if more than 50 rows were created INSIDE the write window — not merely on a head over 50 rows. What removed #761's stall is retiring the probe, not the walk; the walk's value is that the gate's one fail-toward-SUCCESS path no longer depends on an UNDOCUMENTED ordering the server honours only coarsely (page 1 came back 114,112,113,111,110). This RETIRED #751's conservative page-2 probe, which treated "there are rows I did not read" as "assume raced" and so repaired every head that outgrew one page: it fired on Renovate PR #761, reporting a human verdict as overwritten when none existed and then, the sentinel being sticky, refusing to re-exempt that head on every later run. The two directions are NOT symmetric. POST-WRITE, uncertainty fails CLOSED — an unreadable history, an over-cap history, or a count that is not a number all repair to pending; previously an unreadable history warned and left the exemption green while the page-2 probe repaired on the same uncertainty, one check disagreeing with itself. PRE-WRITE, a PARTIAL list still yields a mark, because the mark gates the post-write check entirely and refusing one SKIPS that check, which is itself the fail-open; this rests on the DESC DEFAULT — the newest row, carrying the maximum id, is on page 1, so a walk that fails later still saw it — while a VALIDATED empty history is NOT abandoned (it yields a mark of 0, correct for a first run, since every later row is newer) — what abandons the mark is a read that both FAILED and returned nothing — which since 2026-08-29 WITHHOLDS the exemption before the POST and marks the head with the reconcilable sentinel, rather than posting a green nothing can check (#849) — and a NON-EMPTY history carrying no numeric id is reported unusable rather than collapsed to 0. BOTH id comparisons are NUMERIC-ONLY: jq orders strings above every number, so one "id": "99999" inflates the mark until nothing looks newer, and .id > $since reads any string id as newer than any mark — making a PRE-EXISTING base-mismatched verdict look raced on every run, a permanent per-sha stall (the twin was live on main). An EMPTY post-write history is REJECTED: the walk terminates on an empty page, which is correct before the write and impossible after it (one row per POST), and a well-formed "no statuses exist" is not retried — so accepting it would conclude raced=0 from a list that cannot be real, silently. That is NOT the withdrawn currency witness, which asked whether ANY row sat above the mark and was satisfied by an unrelated newer row; this asks only whether the list is EMPTY, a state no unrelated row can produce. The ::error:: now names its own cause, of which there are THREE — a verdict actually FOUND, a read that could not be COMPLETED, and a read that completed but returned an IMPOSSIBLE answer (the third is not a variety of the second) — while WHICH sentinel description is written is itself part of the answer since #849: only an arm that actually COUNTED a verdict row may write the repair sentinel, and every "could not check" arm writes the reconcilable one (ci.verdict-unverified-write-sentinel). Both are fixed points the classification recognises. .creator is TYPE-TESTED before it is indexed: .creator != null and .creator.login hard-errors on a non-object creator, jq exits 5, and under set -e that took the step down AFTER the exemption was posted and BEFORE the repair — a forged green reported as an infrastructure error. |
2026-08-03 | link |
ci.verify-locally-ci-confirms |
Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt. | 2026-07-21 | link |
ci.web-test-per-test-timeouts |
Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test. | 2026-07-21 | link |
ci.workflow-dispatch-ref-unrestricted |
Gitea 1.27.1 offers NO mechanism to restrict workflow_dispatch by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema CreateActionWorkflowDispatch makes ref a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; /api/v1/settings/actions 404s; the config file the running server actually loads (/etc/gitea/app.ini, named by its own --config) sets only ENABLED and DEFAULT_ACTIONS_URL under [actions]; and the gitea CLI exposes exactly ONE Actions subcommand, gitea actions generate-runner-token, which registers a runner and restricts nothing. Treat the VERSION, not the stale-after date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (ci-image.yml, docker-build.yml, dependency-scan.yml, renovate.yml) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that dispatch is not the cheapest route to ANY of it: docker-build.yml triggers on pull_request:, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (ci.gate-trigger-base-resolved, verbatim: "any PR-added workflow can reference RENOVATE_TOKEN, a write:repository bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: renovate.yml's RENOVATE_TOKEN/GH_COM_TOKEN are reachable from a PR without dispatching renovate.yml at all, and dependency-scan.yml references no secrets. whatever — which corrects #853's own table row for it. On the registry credential as it stands, SIX jobs in docker-build.yml hold REGISTRY_PASSWORD and run on the PR route (toolchain-preflight, test, migrations, functional-e2e, api-docs, format), two of them — test and migrations — branch-protection required contexts per .gitea/required-status-contexts.json; carry that as the INVARIANT "every job on the PR route that NAMES secrets.REGISTRY_PASSWORD", never as the six-name list, because a remediation scoped to a stale list misses whatever lands next. Resist the tempting "every container: job" — toolchain-preflight is deliberately container-free and takes the credential through ETV_REGISTRY_AUTH, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. "Deliberate act" throughout carries ci.toolchain-image-publish-is-a-dispatch's sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals worth tracking are the PR route AND the v* tag push — a single act, explicitly outside release.main-direct-push-disabled — both in #885, not dispatch. |
2026-08-30 | link |
ci.workflow-run-body-no-expressions |
A run: body is not shell when the runner reads it: the runner scans the whole scalar for the expression opener and, on finding one, rewrites the ENTIRE body into a single format(...) call. That rewrite is all-or-nothing, so a payload that does not evaluate fails the interpolation of the whole scalar — and the runner then DROPS THE STEP AND CONCLUDES THE JOB success. A shell comment is therefore NOT inert. In .gitea/workflows/review-verdict.yml no expression delimiter may appear in ANY run: body, in code or in prose, because a dropped step there is a dead merge gate rather than a failed build; pass values in through the step's env: block, which is interpolated per value so a bad payload cannot take the body with it, and describe an expression in prose by NAMING it (a github.event.pull_request.number expression) rather than quoting the delimiters. Repo-wide the rule is weaker and its reach must be stated precisely rather than generously: every expression payload in every workflow field must have a HEAD TOKEN naming a context or function the runner can resolve. That catches the defect above and a nonexistent context; it does NOT catch a syntactically invalid payload whose tokens are all known (${{ github.ref == }}), a renamed output (every token after the first is skipped), or an unclosed opener — those need an expression parser, and the guard is kept permissive on purpose because a red here blocks every merge through the combined status. In review-verdict.yml specifically, any step whose non-execution is consequential is paired with a start-marker guard that FAILS the job when the marker is absent, and that guard's own body must be expression-free — a guard the guarded mechanism can silently delete is worse than none. That pairing now also covers docker-build.yml's test and migrations jobs, where a dropped step is fail-OPEN (the required check goes green having done no work) rather than fail-closed as it is here — see ci.required-job-step-execution-markers, which adds per-STEP markers there and extends this file's delimiter ban to those two jobs. It is still not a repo-wide property, but the remaining exceptions are narrower than this record originally said: build was brought into the ban too (its Smoke + IPTV E2E runs AFTER the image is pushed, so a drop there ships an unsmoked release candidate — its two payloads moved to env:, so the ban was free), leaving only api-docs and format, whose one github.base_ref each sits in a detect step that gates nothing that ships. |
2026-08-06 | link |
concurrency.diff-scalar-fanout |
The frozen Block optimistic-concurrency recipe (api-conventions §7a) fans out to Collection/Playout×2/MultiCollection/RerunCollection, keeping a guard-returned PreconditionFailedError out of any handler's generic catch(Exception)→422 mapping, and preserving each aggregate's existing SaveChangesAsync() > 0 gate semantics under the new unconditional Version++. |
2026-07-11 | link |
concurrency.etag-rotation-completion |
Every handler that mutates a versioned root's editor-visible config state must bump Version (rotating the ETag) with no per-aggregate carve-outs, short-circuiting on a genuine no-op before the bump so idempotent re-submits don't fire spurious rebuild fan-out; SaveChangesForcingVersion rebases the retry (stored + pending delta), never adopts the stored token verbatim. |
2026-07-12 | link |
concurrency.force-write-non-ifmatch |
Any handler that leaves a versioned root Modified or Deleted but takes no If-Match (deletes, item add/remove bumpers, scalar-config writers) must save through ConcurrencyExtensions.SaveChangesForcingVersion — force-write past a concurrent Version bump rather than throw an unhandled DbUpdateConcurrencyException (500). |
2026-07-12 | link |
concurrency.idempotent-concurrent-add |
A concurrent duplicate Add*ToCollection that loses the race on the composite-key unique constraint is treated as an idempotent no-op (skip the reindex/rebuild fan-out), not a 500 — detected via a provider-specific TvContext.IsUniqueConstraintViolation delegate defaulting to "no". |
2026-07-18 | link |
concurrency.ifmatch-rfc7232 |
ConcurrencyHeaders.ParseIfMatch is a real RFC 7232 entity-tag/list parser: a syntactically-valid tag that doesn't strong-match (weak/empty/non-canonical/out-of-range/list) returns 412, and only a genuine grammar violation returns 400. |
2026-07-12 | link |
concurrency.replace-all-contract |
Replace-all aggregate PUTs carry a uniform plain int Version concurrency token (EF .IsConcurrencyToken()), checked pre-save and enforced by the EF UPDATE guard, returning 412 (not 409) on a stale If-Match. |
2026-07-11 | link |
concurrency.schedule-item-child-identity |
PUT /api/schedules/{id}/items reconciles by an optional round-tripped child Id (null/absent/0 ⇒ new item), never by array position, so fill-group/shuffle state follows the logical item across reorders; an unknown or duplicate id is rejected 422 (checked after the §7a CheckVersion, so 412 precedes 422). |
2026-07-11 | link |
docs.convention-docs-session-start |
Docs-first, not source-first: conventions (api-conventions, spa-conventions, e2e-local, blazor-route-parity, domain-model, decisions, README) are read from docs, not reverse-engineered from code, via docs/README.md's task-signal map — only the sections it points to for the task at hand, not the whole set. Each doc is updated in the same PR that changes what it documents, replacing deferred/follow-up doc updates. |
2026-07-07 | link |
docs.corpus-size-signal |
The corpus's size signal is a per-record prose ceiling (decisions_validate.py --record-ceiling, default 60, chosen at a natural gap in the distribution), reported as a NON-BLOCKING ::warning:: naming each record over it. The aggregate prose total is still printed every run but carries NO threshold — it is a ::notice:: trend only — because a total over a monotonically growing corpus can only ratchet, and the generated catalog (docs/decisions/README.md) is no longer counted at all since it gains one row per record and cannot be consolidated away. Being listed by the ceiling is an invitation to check for REDUNDANCY, never an instruction to cut: a long record that is all distinct findings is a legitimate decline, and should be recorded as one. The ceiling's CALIBRATION is guarded in two pieces of different robustness (#688): the blocking test asserts only the coarse, non-ratcheting property that the ceiling flags a MEANINGFUL MINORITY of records (0.02 <= fraction_over <= 0.25), while the fine claim — that it sits between p90 and p95 — is REPORTED by main() as a ::notice:: and never asserted against the live corpus. A ceiling drifting out of date is the passage of corpus growth, not a defect in the commit under test, so it gets stale_records' treatment rather than a red in the blocking script-tests job. |
2026-07-26 | link |
docs.decision-lifecycle |
every decision ## record (active or archived) carries a 5-field metadata block (key, status, since, supersedes, superseded-by) checked by scripts/decisions_validate.py; a record is never deleted or line-edited to reverse a call — it is moved to docs/decisions/archive/ with status: superseded/retired and a reciprocal superseded-by/supersedes key pair to its replacement. |
2026-07-21 | link |
docs.decision-one-file-per-record |
Each decision record is its own file at docs/decisions/records/<area>/<topic>.md (archived ones at docs/decisions/archive/<area>/<topic>.md) with YAML frontmatter; the filename IS the key, so one-active-record-per-key is a filesystem property rather than a validator check, and supersession is a git mv. |
2026-07-25 | link |
docs.decision-optional-provenance |
Decision records gain two OPTIONAL fields — stale-after: YYYY-MM-DD on the metadata line and a **Sources:** line in the metadata block; the Open Knowledge Format (OKF) itself is NOT adopted as the record format. |
2026-07-25 | link |
docs.frontmatter-pyyaml-crosscheck |
decisions_validate.py runs pyyaml_frontmatter_faults() over every record-wing file: it loads the frontmatter with PyYAML and reports an ERROR when PyYAML rejects the document OR when any key's value differs from what the dependency-free dl._read_frontmatter read. PyYAML is the WRITER of these files (migrate_decisions_split.render_record emits them with yaml.safe_dump), so on any disagreement PyYAML is authoritative and the defect is in the FILE, not in either parser. The check is strictly additive: when PyYAML is not importable it is SKIPPED and main() says so with a ::notice::, never silently — the read path stays dependency-free because decisions-guard, the Husky hooks and contributor machines install nothing. The comparison has exactly ONE implementation, called by both the validator and test_frontmatter_reader_matches_pyyaml_on_every_real_record, so the suite and the tool cannot drift on what "matches PyYAML" means. |
2026-08-04 | link |
docs.no-session-narrative |
Every durable artifact — an in-repo docs/ page, a skill, a README, a code comment, an Obsidian vault page — records the END STATE. The path to that end state goes in the commit message, the Gitea issue, or the issue's ## Closing record; it does not go in the artifact. Concretely: a review finding is answered in the commit message, and only the corrected claim enters the doc. Naming the destination is load-bearing — "do not write it in the doc" with no home loses the knowledge, and this repo has the inverse failure on record too (#542, where a pruned narrative turned out to be the only copy). THE TEST IS WHO BENEFITS: if only the author's timeline explains why a sentence is there, it is narrative and belongs in the commit; if a reader who never saw the session would act differently knowing it, it is a finding and stays. Session narrative reads as: first person or session chronology ("I initially thought", "an earlier draft counted", "my first attempt returned 0"), a correction of a belief the reader never held ("this was wrong, actually X" where only X matters), relative time ("earlier today", "currently investigating"), or a blow-by-blow diagnosis standing in place of the conclusion. THE CARVE-OUT, which must be stated or the rule gets over-applied — reader-facing history that must survive: a decision record's supersedes/superseded-by; a dated measurement or an explicitly stated snapshot boundary; a TESTED-AND-REJECTED negative result, kept so nobody re-proposes it on plausibility; the why behind a non-obvious choice; and a trap together with its consequence. docs/decisions/records/** and docs/decisions/archive/** are exempt WHOLESALE: a record narrating how a rule was got wrong is carrying the rationale it exists to carry. docs/superpowers/** — the dated plans and specs — IS reached by the rule (#812), and is NOT exempt from the detector either. The narrative sites IDENTIFIED there were remediated with the rest of the corpus — identified, not all, since no sweep here claims closure over a phrasing space. The one surviving detector hit is a carve-out on its merits, keeping a rejected design together with the concrete harm that killed it. A wholesale detector exemption for that path was proposed and REJECTED, recorded so it is not re-proposed on plausibility: the argument was that all 35 files are frozen (measured 2026-08-29, before this change: nothing had been edited there since 2026-07-23), so sweeping them yields only false positives. But --diff, the mode CI runs, scans ADDED lines, and a frozen file contributes none — so the exemption buys nothing where the check actually runs, and what it WOULD suppress is any plan being written OR REVISED now — 15 of the 35 have more than one commit, so revisions do happen — which is exactly the case where the remedy (move it to the commit message) is still available. It costs the detector's only reach to buy quiet in a sweep a person runs deliberately. ENFORCEMENT IS ADVISORY ONLY — scripts/check-doc-narrative.py, run non-blocking from the docs-reminder job over ADDED lines. It is a string predicate over prose and may never become a blocking gate. |
2026-08-21 | link |
docs.record-wing-parse-guard |
decisions_validate.py asserts, per PATH, that every *.md under docs/decisions/records/** and docs/decisions/archive/** parses to exactly one record carrying a key — an ERROR, not a warning, since a file in the record wings that is not a record is a mistake by definition. A file sitting DIRECTLY in archive/ is exempt only when it actually looks like a #610 stripped index — exactly one keyless record with a known generated heading — never merely by living there. The one other exemption, archive/README.md, is by exact RELATIVE PATH; nothing is ever exempt by BASENAME, since that would exempt the same filename in the active wing too. _read_frontmatter is deliberately NOT extended to accept YAML block scalars: every record value goes on ONE line, and the structural check is what makes that limitation loud instead of silent. |
2026-07-26 | link |
docs.tracker-comment-retrofit |
When the knowledge exporter flags an over-cap tracker issue and excludes it from ingestion, triage its comments instead of assuming a retrofit is owed — and for each decision-shaped item check the worked issue first, because a tracker session comment is by construction a précis of the fuller closing record posted on the issue it narrates. Applied to #237 (111 comments) this yielded zero records, so server-management#642's "a fact found only in a #237 comment" retrieval row has no valid subject and its interim target (an already-migrated record) is permanent. | 2026-07-21 | link |
ffmpeg.external-logo-graphics-engine |
External-URL channel logos pass through to the graphics engine like any other watermark source; WatermarkSelector must never gate them on File.Exists (always false for a URL) and never route them through the ffmpeg-native overlay shortcut. |
2026-07-20 | link |
ffmpeg.hls-cold-start-burst |
HLS cold-start latency is fixed with a bounded -readrate_initial_burst (gated on FFmpeg ≥6.1 capability detection), not by raising work_ahead_limit, which would remove the concurrency guarantee it exists for. |
2026-07-20 | link |
ffmpeg.qsv-decode-encode-split |
QSV decode is decoupled from QSV encode via a single FFmpegProfile.QsvPreferNativeDecoder bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum. |
2026-07-20 | link |
ffmpeg.qsv-extra-hw-frames-floor |
a QSV upload never emits extra_hw_frames below FFmpegState.MinimumQsvExtraHardwareFrames (64); a stored 0 or negative value is treated as "no pool configured" rather than honored literally, because with no headroom any unthrottled read exhausts the pool and the transcode writes nothing at all. |
2026-07-21 | link |
ffmpeg.qsv-hdr-tonemap-opencl |
the QSV pipeline never emits vpp_qsv=tonemap=1, which is a SILENT no-op on pre-Gen11 Intel graphics; HDR is tonemapped on the GPU via hwupload=derive_device=vaapi → scale_vaapi → hwmap=derive_device=opencl → tonemap_opencl when a VA-API device exists, the frames are still in software, and tonemap_opencl is available, and by the software TonemapFilter otherwise. The scale runs BEFORE the tonemap, and any hardware filter on the path forces the output to be re-tagged bt709. |
2026-07-26 | link |
ffmpeg.readrate-catchup-sparse-streams |
a realtime video/audio input also gets -readrate_catchup (6.0) when the binary supports it — but NOT a still-image input (mirroring the #350 exclusion) and NOT a concat input, which keep at most bare -readrate (a still image's video input takes none at all). Reason: -readrate paces the whole input off its furthest-behind stream, so a sparse stream sharing that input (an embedded PGS/DVD bitmap subtitle feeding the overlay) otherwise pins output at ~0.53x realtime. Catchup is a ceiling that applies only WHILE an input is behind, never a target, so it does not let a caught-up input race ahead. |
2026-08-04 | link |
ffmpeg.remote-image-fetcher-bounded |
remote graphics-engine images are fetched through IRemoteImageFetcher with a pooled HttpClientFactory client, a body-covering deadline, a wire-transfer size cap, and a decoder-enforced DecoderOptions.MaxFrames bound re-verified post-decode — never cached, re-fetched per element init. |
2026-07-20 | link |
ffmpeg.watermark-resolution-unified |
Every watermark WatermarkSelector resolves goes through one shared ResolveWatermark — the playout-item, channel and global precedence levels AND the deco path, for all three ChannelWatermarkImageSource values. An unresolvable watermark (missing file, un-migrated external URL, or no logo artwork) resolves to no on-screen bug plus a warning, never a dead path or a URL handed downstream; the one deliberate exception is a playout-item Custom with a blank image, which still falls THROUGH to channel/global. The generated-initials fallback is therefore off everywhere, including the deco path where it demonstrably rendered. Watermarks built OUTSIDE the selector (the song-progress overlay, #653) are not covered and remain unchecked. |
2026-07-26 | link |
ffmpeg.work-ahead-slot-atomic |
workAheadSegmenterLimit is enforced by a single compare-exchange claim on a shared WorkAheadSlots pool taken by the caller of Transcode, which then passes ownership in and gets the release in Transcode's finally — never a Volatile.Read compare in one place and an Interlocked.Increment in another. |
2026-07-21 | link |
ffmpeg.work-ahead-slot-release-never-negative |
Release() reads the count and compare-exchanges current - 1 only when current > 0; a release against an empty pool records an unbalanced release and returns false without ever writing a negative value. It never decrements first and clamps afterward. The single caller (HlsSessionWorker.Transcode's finally) logs a warning on the false return. |
2026-07-21 | link |
graphics.channel-level-attachment |
A channel can attach GraphicsElements directly via a new ChannelGraphicsElement join table (a base layer under deco/playout-item elements), and a built-in text element (on-now-next.yml) is seeded once per database so the On Now/Next overlay works out of the box. |
2026-07-22 | link |
graphics.channel-logo-caching |
An external http(s) channel-logo URL is fetched, decode-budget-validated, and stored in the image cache under a content-hash name at SAVE time — becoming byte-identical to an uploaded logo — so the render path never fetches a logo over HTTP; a bad URL fails the save with a 422 (BaseError → ValidationProblemDetails). |
2026-07-21 | link |
graphics.on-now-next-on-by-default |
The built-in On Now / Next element is attached to new channels by ChannelGraphicsDefaults.Attach, called from BOTH create paths, and to pre-existing channels by a one-time AttachOnNowNextByDefault backfill guarded by graphics.on_now_next_default_attached. The marker is written only once the built-in element RESOLVES, so an install whose element row does not exist yet is retried rather than stranded; the cost is that while the backfill is still armed it cannot tell a deliberately cleared channel from an untouched one. HLS Direct is excluded at both sites. |
2026-08-26 | link |
graphics.seeded-template-upgrade-by-fingerprint |
GraphicsElementSeeder keeps every default it has ever shipped as a verbatim fingerprint; on an already-seeded database it rewrites the on-disk template only when the file still matches one of them (line-endings and trailing whitespace normalised), so an untouched install gets the new default while any operator edit is left alone. |
2026-08-26 | link |
iptv.base-url |
An optional advertised base URL (iptv.base_url) is resolved centrally via a pure Core helper (AdvertisedBaseUrl) inside the two IPTV generation handlers (M3U + XMLTV); unset/malformed values fall back byte-identical to the request-derived host, and it's a new iptv settings group distinct from ETV_BASE_URL and out of scope for HDHomeRun. |
2026-07-16 | link |
iptv.logo-drives-bug-preset |
One uploaded channel logo drives both the listing logo and the on-screen bug via a shared, seeded ChannelLogo-sourced watermark preset (Channel Bug), not new per-channel schema. |
2026-07-20 | link |
locking.entitylocker-atomic-flags |
EntityLocker uses Interlocked.CompareExchange-guarded atomic flags plus a documented single-owner-release discipline (no owner tokens/leases); Unlock* on an already-unlocked slot returns false and logs a Warning rather than throwing. |
2026-07-11 | link |
mcp.server-foundation |
ErsatzTV.Mcp is a fresh stdio JSON-RPC server wrapping frozen /api/v1 with explicit narrow per-endpoint tools, read-only-by-default enforced at runtime (ERSATZTV_ALLOW_WRITES), machine-key auth, and opt-in If-Match. |
2026-07-20 | link |
mcp.tool-schema-openapi-parity |
Every POST/PUT/PATCH tool in ToolCatalog declares exactly the request-body properties its endpoint accepts, each with a matching type, and EVERY tool (read and write) declares exactly its endpoint's query parameters, both asserted against the generated ErsatzTV/wwwroot/openapi/v1.json (linked into ErsatzTV.Mcp.Tests) by Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields and Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters. A field the endpoint accepts but the tool omits is a DEFECT, not a deferral: on the full-replace tools (channel update, schedule update, custom-order) the omission is silently applied as a clear. The write tools are NOT uniformly full-replace — add-collection-items is additive, and several leave an omitted field unchanged — so each tool description states its own semantics. An omitted query parameter is UNREACHABLE, not merely undocumented, because ToolArgumentValidator rejects undeclared arguments. |
2026-08-06 | link |
media.lastscan-null-boundary |
A never-scanned LastScan surfaces as null at the API/MCP boundary, not the 0001-01-01 MinValue sentinel — enforced by an ongoing read-boundary coercion plus a one-time data migration cleanup. |
2026-07-18 | link |
media.nullable-primitive-collection-mutation |
Code that reads a nullable EF PRIMITIVE COLLECTION guards it at the read site — hoisted into a LOCAL — and NEVER writes the guard back onto the entity with ??= []. The local is the load-bearing half and it is universal; the SUBSTITUTED VALUE is not, so read to the end before copying a form: Optional(x).Flatten() (i.e. EMPTY) is right for the SongMetadata pair and WRONG for the six scheduling columns, which substitute the All*() sets. Such a collection is stored WHOLE, in ONE COLUMN, so unlike the navigation collections that the same ??= [] idiom guards harmlessly all over this repo, the property IS the column value: assigning it on a TRACKED entity flips the entry to Modified and the next SaveChanges persists [] over what the database held as NULL. The population is derived from the MODEL, not from a grep of the domain classes, and is currently EIGHT columns: SongMetadata.Artists/AlbumArtists (EF-native primitive collections, no explicit configuration, stored as a JSON array) plus the six value-converted collections registered in ErsatzTV.Infrastructure/Data/Configurations — ProgramScheduleAlternate and PlayoutTemplate each carrying DaysOfMonth, MonthsOfYear (IntCollectionValueConverter, COMMA-SEPARATED text, not JSON) and DaysOfWeek (EnumCollectionJsonValueConverter, JSON). The shared property that matters is not the serialization format but that the collection is ONE SCALAR COLUMN, so do not reason from EF-native primitive-collection behaviour when standing in front of a converter. Of the eight, only the SongMetadata pair is left NULL by a live code path (FallbackMetadataProvider never assigns it); for the six the NULL is legacy-only and narrow, per the migration analysis below. No site applies ??= to any of the six, so THIS defect has no instance there — but a runtime null IS REACHABLE and is now guarded (#823, MEASURED 2026-08-29 against a real TvContext on BOTH providers, SQLite and MySQL 8.4). The measurement overturned the question's own framing: the two converters differ on their read side (IntCollectionValueConverter maps null-or-blank to Array.Empty<int>(), EnumCollectionJsonValueConverter would dereference JsonConvert.DeserializeObject), so the expectation was that they behave differently on a NULL row. NEITHER RUNS: EF does not invoke a value converter for a NULL column at all, so all six materialize as CLR null and the int converter's null-to-empty branch is DEAD on this path — do not reason from the converter bodies here. The WRITE path ACCEPTS a null: assigning one and calling SaveChanges SUCCEEDS and stores SQL NULL, because the COLUMN is nullable and the converter is skipped outbound too. Separately — this is how a null could REACH the entity, which is a different question from why the save succeeds — only the HTTP request records normalize with ?? [], while ReplacePlayoutAlternateScheduleItemsHandler and ReplacePlayoutTemplateItemsHandler assign the command value straight onto the entity. State that precisely: NO caller supplies a null today — every production construction of the two commands goes through the request records — so this is a property of the CODE, not evidence of a live caller, and writing it down as "a non-API caller does this" would be the banned AsNoTracking-today argument pointed the other way. The LEGACY route is narrower than "the columns are nullable", and the difference matters: 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. EXACTLY ONE case is code-path-free, and it is the one to go and check: Sqlite's 20240113140741_Add_PlayoutTemplate_DaysOfMonth is an AddColumn with nullable: true and NO defaultValue, so PlayoutTemplate rows inserted before it hold NULL. On MySQL PlayoutTemplate arrived whole in 20240114034944_Add_BlockScheduling, so there is NO code-path-free NULL for any of the six there. Unguarded, AlternateScheduleSelector's three .Contains calls throw NullReferenceException. The guard is three LOCALS in GetScheduleForDate, never assigned back onto the item. A null reads as UNRESTRICTED — the All*() sets — and the REJECTED alternative was EMPTY (the item matches nothing and the loop moves on). Both readings were written and one was shipped, so the rejection is recorded to stop it being re-adopted. What does NOT decide it: the API's ?? [] must not be cited as if it did — that is a client omitting a field on a WRITE, not evidence about what a legacy database NULL meant, and citing it was the first draft's actual error. What DOES decide it is the single (column, provider) case that is code-path-free: Sqlite's 20240113140741_Add_PlayoutTemplate_DaysOfMonth adds the column with no defaultValue, so a PlayoutTemplate row inserted before it holds NULL and, BY CONSTRUCTION, had no day-of-month restriction — it applied on every day of the month. Reading that NULL as empty INVERTS the row's meaning and silently stops the template applying at all, which is strictly worse than the throw it replaces; All*() preserves it. Note there is no correct behaviour being preserved in the general case either, since the row THROWS today and fails the playout build outright — so the choice is between two silent repairs, and the one that keeps a legacy row doing what it did wins. The SAME substitution is applied at the entity→DTO boundary in both Mapper.ProjectToViewModel overloads, because the selector is not the only reader: the SPA spreads these collections (daysOfWeek: [...template.daysOfWeek], PlayoutScheduleEditors.tsx) and appliesToDate — an exact port of GetScheduleForDate — calls .includes on them, so a JSON null is a TypeError and a DTO disagreeing with the selector would mispreview the calendar. That boundary ROUND-TRIPS, and it is the reason the empty reading was actively dangerous rather than merely debatable: the draft the SPA builds from the DTO is PUT back whole, so whatever the mapper substitutes is what a user's next save PERSISTS over the NULL. With All*() that write is benign — it stores exactly what the selector already behaves as, making the implicit explicit. With [] it would have silently rewritten the row to 'matches no day', which is a read guard turning into data corruption one save later. THREE RESIDUALS, stated rather than argued away. (1) The substitution is SILENT: a legacy NULL row changes from 'playout build throws' to 'the item applies', with nothing logged — the static, hot-loop selector has nowhere to log from. The LOUDNESS change is the real cost and it is worst for an all-three-NULL ProgramScheduleAlternate, which now matches unconditionally and shadows the playout's default schedule for every date where it previously threw. That state is unreachable today (nothing writes it on either provider) and the reversal is justified by a case where the OTHER dimensions are non-NULL, so extending it to the all-NULL row is a choice over an unreachable state rather than a measured requirement. (2) The normalization is ONE-WAY and WHOLE-LIST: both PUT paths are full replaces, so editing any row in a playout persists All*() over EVERY NULL row in it, including rows the operator never opened — and once written, 'the operator selected all 31' and 'this is a pre-2024 legacy row' are no longer distinguishable, so revisiting the unrestricted reading later is a door an ordinary user action closes. (3) The WRITE side disagrees with the READ side about what ABSENCE means: the request records normalize an omitted daysOfWeek to [], which reads as 'matches no day', while a NULL column now reads as unrestricted — so a client omitting the field gets HTTP 200 and a row that silently never applies. That asymmetry predated this record and was deliberately NOT resolved here, because a client omitting a field on a write is a different question from what a legacy NULL meant. It is now CLOSED by api.absent-collection-means-unrestricted (#880), which splits the write side in two rather than copying this record's answer across: an ABSENT array normalizes to the SAME All*() symbols the read side substitutes, so the two halves of 'this field is absent' finally agree, while an EXPLICITLY EMPTY [] — a request this record never had a view on — is rejected with a 422 instead of being stored as a row that can never apply. For these six the guard form is ?? All*() into a local, NOT this record's Optional(x).Flatten(), and the deviation is SEMANTIC rather than stylistic: Flatten() yields EMPTY, which is the reading rejected above, so the idiom cannot be reused here whatever its ergonomics. Do not restate this as a performance argument — an earlier draft did, claiming .ToList() 'allocates for nothing', which is both irrelevant to the choice and false about the shipped code, since AllDaysOfMonth()/AllMonthsOfYear() are themselves Enumerable.Range(...).ToList() on the null path. What carries over from the Optional(x).Flatten() form is the only part that was ever load-bearing: a LOCAL, never assigned back onto the entity. A grep for IList<string> finds only two of the eight and an enum collection none of them — so a sweep follows the FIELD via the model configuration, not the file: the mutation and every read it was protecting must move together, or removing the assignment trades a silent write for a live throw. The read FORM decides which throw, and BOTH occur in this one PR: foreach over a null collection throws NullReferenceException (the two Lucene reads — measured), while string.Join/Enumerable.ToList on a null SOURCE throw ArgumentNullException (the two Elastic reads, and the #671 mapper sites). Neither exception type alone characterises the class, so neither grep finds it — which is the actual reason a sweep must follow the FIELD rather than an exception name. Whether today's callers happen to be AsNoTracking is NOT the safety argument and must not be written down as one: it is a property of the current callers, not of the code holding the entity, and it is what #691 recorded as a loaded gun. |
2026-08-22 | link |
media.remote-stream-probe |
ValidatePlayoutItemPath probes the Plex/Jellyfin/Emby remote-stream URL via IRemoteStreamProber before returning it; only a redirected 404 fails closed (PlayoutItemNotAvailableFromMediaServer), everything else fails open, and there is no toggle. |
2026-07-19 | link |
media.remote-stream-probe-externaljson |
External-JSON playout channels' StreamRemotely now probes the remote-stream URL through the same IRemoteStreamProber seam as the generated-playout path, closing the #473 scope gap for a channel kind with no DB PlayoutItem rows. |
2026-07-20 | link |
media.source-mgmt-write-api |
Media-source management (local/Plex/Jellyfin/Emby) is a REST write API + SPA under /app/libraries/*, wrapping existing MediatR commands 1:1 with no new commands or DB migration; connection GETs never leak a stored apiKey, and each PUT-replace family's identity contract is documented per-family (not assumed uniform). |
2026-07-11 | link |
process.bom-format-detection-recipe |
Before any push touching .cs, detect BOMs with the od -A n -t x1 -N 3 byte check and verify the format gate with dotnet format --include run under bash -c, never bare zsh. NOT xxd: it ships with vim and is absent on plain Linux hosts including this repo's CI runner, where the substitution yields empty, never matches, and the check reports all-clean — the same all-clean-detector failure this record was written about, in the detector it prescribed. |
2026-07-21 | link |
process.branch-off-feature-branch |
To fix work on an unmerged feature branch, branch off that branch and land by fast-forward push — and after creating a worktree, drive the first Edit/Read from ITS absolute paths and git status it before building. |
2026-07-21 | link |
process.build-concurrency-limits |
Run at most 3–4 concurrent dotnet/npm builds on this Mac, gate launches on FREE RAM rather than CPU load, and never set ETV_UPDATE_GOLDENS / ETV_UPDATE_PLAYOUT_GOLDENS. |
2026-07-21 | link |
process.check-and-use-pins-a-version |
Where a CHECK authorizes an ACTION over state that can change in between, the two are bound to ONE version of that state. Binding alone is not enough and is the half that keeps being skipped: a snapshot nothing re-validates is not pinned, it is a stale read wearing a version number. Three substrates, three mechanisms, and they are the SAME rule — in-process, a compare-exchange claim taken by the caller, never a Volatile.Read in one place and an Interlocked in another (ffmpeg.work-ahead-slot-atomic); over our own HTTP API, RFC 7232 If-Match/ETag, with the force-write path named explicitly rather than left implicit (concurrency.ifmatch-rfc7232, concurrency.force-write-non-ifmatch); against a remote service, a full commit sha, an image digest or a monotonic event count re-read immediately before the write. Prefer true compare-and-set where the server offers it. Where it does not — Gitea's commit-status API has no ETag, no If-Match and no expected-previous-state — the ceiling is READ-COMPARE-REFUSE: re-read the identifier immediately before the write and FAIL CLOSED on any movement, which narrows the window to one round trip and makes the loss observable instead of silent. A residual that cannot be closed is STATED in the code and carried in docs/remote-state-inventory.md as UNSAFE-KNOWN with the reason it is tolerable; "noticed" is not "accepted". Two identifier traps are load-bearing here: compare the FULL sha, never a 7-char prefix, and compare a base BRANCH REF rather than its tip sha, because the tip moves on every unrelated merge and comparing it deadlocks every open PR. Finally, and this is the failure #778 actually found: a mitigation that lives OUTSIDE the code relying on it — branch protection, a required status context, a server-side refusal — must be VERIFIED at the point of use, not asserted in a comment or in the reason string a human reads. A dated claim about configuration is not a check, and it is worse than no claim, because it talks the next reader out of looking. |
2026-08-16 | link |
process.codex-cheap-worker-launch |
For bounded tool-bearing selector/recon work, launch a Codex worker with codex exec -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only; spawn_agent buys parallelism but no cost savings. |
2026-07-21 | link |
process.consistency-fix-new-code-scrutiny |
Review a "make X consistent with Y" change as new code, not as a mechanical copy — and for any timer or effect involved, ask explicitly "when does this fire?", including on mount. | 2026-07-21 | link |
process.enumerate-workaround-behaviors-before-deleting |
When an issue says "delete X", enumerate every behavior X provided before removing it — a workaround often serves a second purpose that outlives the first. | 2026-07-21 | link |
process.ersatztv-owns-code-not-operations |
This repo owns developing the fork — code, the /api/v1 surface, the MCP server, CI, releases and the canonical ersatztv skill. Channel/collection/schedule/playout OPERATIONS against a running instance belong to media-management; driving prod from here is in scope only as verification of a change this repo is shipping. |
2026-08-26 | link |
process.foreign-worktree-plumbing-merge |
Never commit or merge inside a worktree another session created; land the merge with git plumbing against the branch ref instead. | 2026-07-21 | link |
process.harden-with-runtime-posture-not-clamp |
When a security fix constrains a capability the roadmap will later want, make the safe state the DEFAULT OF A SWITCH rather than a wall — and read the feature's own issue for its end-state first. | 2026-07-21 | link |
process.hook-resolves-inputs-from-repo-root |
A hook resolves every path whose CONTENT it sources, executes, or consults to decide, from $repo_root — derived from the hook's own ${BASH_SOURCE[0]} — and never from $CLAUDE_PROJECT_DIR or any other environment variable. There is NO telemetry exemption, and the attempt to write one is instructive: the first draft of this record exempted ETV_HOOK_FIRE_LIB on the grounds that "a wrong log destination is not a wrong verdict", and cold review refuted it by execution — that path is . -SOURCED, so whatever it names runs as CODE inside the hook before stdin is read and before decide exists; a file there that prints an allow decision and exits 0 grants the merge having bypassed every check in the file. A path's PURPOSE does not bound its authority; how the hook consumes it does. The failure that matters is not an attacker — the same variable already names the hook binary in .claude/settings.json, so a hostile value has chosen which hook runs and the gate is moot before any inner path is read — it is LAUNCHER DIVERGENCE, and it is reachable: husky invokes the prepush hooks as ./.claude/hooks/…, a relative path wholly independent of $CLAUDE_PROJECT_DIR, so the two roots genuinely disagree there. A wrong path holding a plausible file returns a confident answer about another tree; a missing path only asks, so the silent direction is the dangerous one. Two halves of one comparison must never come from two roots. |
2026-08-30 | link |
process.independent-review-rubric |
Run an independent review pass — preferably a different model family, otherwise a cold-context review-only agent — on any diff touching locks/concurrency, auth/security, API write-path handlers, or DB migrations, or larger than ~150 changed C# lines; skip only for a pure-SPA/docs leaf with no server-state effect, and state the skip and its reason in the PR or close comment. | 2026-07-21 | link |
process.issue-qualification-audit |
Run scripts/issue-qualification-audit.sh at session end and label everything it flags, including issues you filed that session. |
2026-07-21 | link |
process.local-gate-before-push |
Run the local build/test gate and a cold-context, scoped "review only" adversarial review over the diff, fold the fixes, and only then push or open the PR. | 2026-07-21 | link |
process.lock-ownership-enumerate-producers |
Before trusting any "single owner / no double release / no cross-release" claim, grep the whole host project for every writer of that channel message (or acquirer of that lock) — the background scheduler/worker is the usual missing producer. | 2026-07-21 | link |
process.one-worktree-one-committing-agent |
Never run two committing agents concurrently on one worktree — give each parallel slice its own worktree branched off the feature branch and merge back. | 2026-07-21 | link |
process.parallel-session-claim |
Before starting an issue, check for an existing claim four ways — open PRs referencing it, remote branches naming it, recent comments (a claim can precede the label), and a fresh git fetch origin main — then claim with the in-progress label plus a comment. A claim prevents duplicate PICKUP, not duplicate WORK. Re-fetch origin/main before every push, not only at branch time. |
2026-07-21 | link |
process.per-agent-model-routing |
State the model tier (and effort, where the client exposes it) in the dispatch itself for every delegated agent — bounded recon → cheapest fast tier at low; mechanical slice against a documented contract → mid tier; judgment-heavy work → orchestrator tier; independent review → a different model family than the implementer. |
2026-07-25 | link |
process.pr-routine-sequence |
Worktree off origin/main → implement → regenerate API artifacts → full local tests + cold review + live-E2E ALL before the push → push, open PR, arm the CI monitor at open → fixes after the push are follow-up commits, never amend/force-push. | 2026-07-21 | link |
process.review-disagreement-frontier-judge |
When independent reviews disagree on a gate PR, escalate to the frontier judge, and put the proposed fix approach in front of it — not just the disputed finding. | 2026-07-21 | link |
process.shared-tree-readonly |
Never commit in /Users/timothy/ersatztv and never read its git log/git status/HEAD to infer anything about main — work in a worktree off origin/main, which is the only source of truth. |
2026-07-21 | link |
process.subagent-drop-resume |
Treat a subagent connection drop as laptop sleep or transient network and re-resume via SendMessage — the work survives. | 2026-07-21 | link |
release.api-contract-ci-gate |
A PR touching ErsatzTV/Controllers/Api/** or ErsatzTV.Core/Api/** must ship regenerated OpenAPI artifacts (v1.json, v1.d.ts, endpoint-index.md) in the same diff, enforced by a blocking api-docs CI job that regenerates-and-diffs against a fresh build. |
2026-07-12 | link |
release.done-when-merge-consent |
A PR may merge only when its linked issue's ## Done-when checklist is fully ticked and the PR's CI is green, enforced by a PreToolUse hook on the Gitea merge tool (deny/allow/ask) plus a pre-push backstop for direct pushes to main. |
2026-07-12 | link |
release.format-as-you-touch-rebase |
A blocking format CI job runs dotnet format --verify-no-changes scoped only to the PR's changed .cs files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on origin/main (never merging main in), enforced by .husky/pre-push → prepush-rebase-check.sh. H11 has ONE always-on carve-out, #719 — a push in which EVERY ref is under refs/tags/ skips the freshness check, because a tag push cannot revert merged work, which is the failure mode H11 exists to prevent, and the release cut tags from a branch that is behind origin/main (observed on the v26.13.0 cut, #719). A push mixing branch and tag refs is still blocked, and so is a push with zero parsed ref lines (the exemption requires at least one, so empty stdin cannot vacuously disable H11). |
2026-07-12 | link |
release.live-e2e-required |
A PR that changes an API write-path handler must include a live-E2E pass (driving the real endpoint/screen and confirming the round-trip through a subsequent read), not only unit/characterization tests, and must state whether live-E2E ran or wasn't required. | 2026-07-12 | link |
release.main-direct-push-disabled |
Branch protection on main carries enable_push: false AND block_admin_merge_override: true. Both halves are required and neither is sufficient. enable_push: false removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates status_check_contexts, and therefore the only path on which review-verdict/h10 is consulted at all. block_admin_merge_override: true then closes the force-merge bypass on that remaining path: with it false (the default), CanBypassBranchProtection returns true for a repo admin, so POST /pulls/{n}/merge with force_merge: true merges a PR whose h10 is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming timothy still admits the push, and timothy is the identity every agent session, PAT and injected GITEA_TOKEN already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (tag_protections governs those separately), so the release cut still works. |
2026-08-05 | link |
release.merge-consent-autogrant |
When Done-when boxes are ticked, CI is green, and a fresh positive Review-verdict references head, the merge-consent hook emits permissionDecision: allow to actually suppress the redundant mechanical prompt — the derived state IS the consent, no separate conversational confirmation on that path. |
2026-07-12 | link |
release.migration-rehearsal-prodcopy |
Before promoting a migration-bearing release, rehearse the new image's migrations against a throwaway copy of the latest prod backup (scripts/migration-smoke.sh), gating PASS on the migrator's completion log line rather than HTTP readiness alone. |
2026-07-12 | link |
release.prepush-clean-worktree-guard |
A fail-open pre-push hook blocks a push when any file in the branch's diff vs origin/main also has uncommitted working-tree or index changes, since a stale-index commit (e.g. git reset --soft + git add over an edited-but-unstaged fix) can silently push, CI-test, and get reviewed a different tree than the one on disk. Scope is precise to pushed-diff files; escape hatch ETV_ALLOW_DIRTY_PUSH=1. |
2026-07-17 | link |
release.promotion-floating-prod |
Prod tracks the floating :prod image reference; a tag build's immutable :<version> image is scanned first, then promotion happens via a separate manual DeployStack, with daily auto-update only as a fallback — tag with enough runway before 03:00 to avoid an unscanned promotion. |
2026-07-13 | link |
release.review-verdict-gate |
A PR may not merge until a Review-verdict: <MERGEABLE|APPROVED|LGTM|BLOCKED|NOT-MERGEABLE> @ <head-sha> comment references the PR's current head sha (short-sha prefix match against the verdict's OWN @ <sha> field, marker at COLUMN 0 (no indent, so indented code blocks cannot self-approve), whole-word verdict token, fenced code blocks stripped with markdown fence-length semantics, negative wins over positive on the same head); folds into the H6 merge-consent hook as condition (c). The grammar lives in ONE tested place, scripts/check-review-verdict.sh — #629 found three false-opens that survived because it was implemented inline and untested while this record described stricter behaviour than the code had. |
2026-07-12 | link |
release.verdict-status-check |
The H10 review verdict is written as a review-verdict/h10 Gitea commit status on the exact reviewed sha by scripts/post-review-verdict.sh, and that context is a REQUIRED status check on main. Because a status belongs to one sha, a later commit cannot inherit it, so Gitea's own merge_when_checks_succeed refuses to merge a head no one reviewed. The PreToolUse hook additionally refuses to SCHEDULE an auto-merge unless that status is already green on head. A pull_request_target workflow auto-passes the two exempt classes (Renovate-authored, docs-only) unless the PR touches a protected path (.claude/, .codex/, .gitea/, .husky/, scripts/, docker/ci/). This extends — does not supersede — release.review-verdict-gate (#303 H10), whose comment convention remains the human-readable artifact and the hook's condition (c). |
2026-07-25 | link |
release.verdict-vocabulary-shared |
The H10 verdict words live in exactly one place, scripts/lib/review-verdict-vocabulary.sh, as two arrays. scripts/post-review-verdict.sh (write: word to commit-status state) classifies through etv_verdict_class; scripts/check-review-verdict.sh (read: comment to classification) builds POS_RE/NEG_RE from etv_verdict_alternation. Neither script may restate a word, and neither may enumerate the vocabulary in a usage banner or an error string. Every word is validated to [a-z][a-z-]* before it reaches a regex. Validation is enforced by a DATA dependency, not a control-flow check: etv_verdict_vocabulary_validate sets ETV_VERDICT_VOCABULARY_OK=1 on its final line, and the derived views refuse to hand out words without it. A vocabulary that is missing, unreadable, truncated, exits at top level, is declared as a scalar, or fails validation yields NO words on either side — exit 1 for the writer (nothing posted), exit 2 for the reader (callers fail closed). Do NOT reintroduce a text-comparison parity test alongside this. |
2026-08-26 | link |
release.verdict-writes-status-before-comment |
scripts/post-review-verdict.sh writes the sha-bound review-verdict/h10 commit status FIRST and the human-readable Review-verdict: comment SECOND. Every refusal path still refuses (fail-closed, unchanged) and exits non-zero, and none of them may leave a verdict comment behind. An orphaned comment is therefore PREVENTED rather than tolerated. If the comment write fails after the status was written, that is an error too, but it degrades to an ask at the merge gate rather than to an apparent grant. |
2026-08-22 | link |
rulebuilder.relative-date-macros |
The visual rule builder's inLast/notInLast date operators compile to/parse from the pre-existing CustomMultiFieldQueryParser macros released_inthelast/released_notinthelast and added_inthelast/added_notinthelast, value form "<n> day|week|month|year"; there is no backend change. |
2026-07-23 | link |
scan.collections-scan-status |
GET /api/v1/media-sources/collections-scan-status reports a family-global (not per-source), boolean-only active-scan set read from IEntityLocker; the SPA reconciles authoritatively against it (with a grace-tick helper) instead of a fixed client-side timeout. |
2026-07-12 | link |
scan.getoraddfolder-db-lookup |
ILibraryRepository.GetOrAddFolder resolves the existing folder via a DB query on (LibraryPathId, Path), not the caller's LibraryPath.LibraryFolders in-memory navigation, since that navigation is only eager-loaded on the local scan path and is null on remote (Jellyfin) callers. |
2026-07-20 | link |
scan.jellyfin-mixed-content-library |
A Jellyfin library whose collection type is mixed (or absent) maps to one ErsatzTV library of LibraryMediaKind.Mixed, scanned by running the movie/television/music-video scanners in sequence against that single library — a library is a place, not a media kind. |
2026-07-20 | link |
scan.libraryfolder-unique-identity |
LibraryFolder uniqueness per (LibraryPathId, Path) is enforced by a database unique index over a SHA-256 PathHash (Path is unbounded and not portably indexable), and LibraryRepository.GetOrAddFolder/SetEtag tolerate the constraint violation by re-reading and adopting the winner's row. |
2026-07-25 | link |
scan.musicvideo-server-identity |
Jellyfin music videos carry a per-library server identity (JellyfinMusicVideo : MusicVideo with ItemId/Etag, TPT table + ItemId index), so JellyfinMusicVideoLibraryScanner folds onto a shared MediaServerMusicVideoLibraryScanner base that diffs the server item id and soft-trashes (FlagFileNotFound) instead of diffing local paths and hard-deleting. Rows predating the identity are adopted in place — the identity row is inserted against the same MediaItem id, scoped to the scanned library's own LibraryPath — never deleted and re-added. |
2026-07-25 | link |
scan.projection-failure-sweep-guard |
MediaServerReconciliationGuard takes a per-enumeration projection-failure count and refuses the file-not-found sweep (logged loudly) whenever it is non-zero against a non-empty existing set — at all six sweeps, including the nested per-show season and per-season episode ones #477 left unguarded (via ShouldFlagMissingDescendants, which applies the failure refusal but not #477's empty-fetch branch). Deliberate guard-clause skips (STRM, virtual, unsupported type) are explicitly not failures and never suppress a sweep. The missing-fraction / ratio threshold floated by #477 is rejected, not deferred. |
2026-07-25 | link |
scan.zero-item-fetch-guard |
A media-server library sweep refuses to flag missing items when a successful fetch returns zero incoming items against a non-empty existing set (MediaServerReconciliationGuard.ShouldFlagMissing), rather than treating an ambiguous empty result as a full-library deletion. |
2026-07-19 | link |
sched.auto-tune-foundation |
Auto-tune preview enumeration uses EF distinct+count queries for exact counts, while each created channel is persisted as a live SmartCollection; coexistence with existing channels/numbers is additive-only, never mutating. | 2026-07-16 | link |
sched.autotune-detailpanel-members |
The Auto-Tune DetailPanel's per-channel content-source list is a live ISearchIndex.Search roll-up through the server-owned AutoTuneAxisMap.GenerateQuery, not an EF distinct+count query, so the preview matches exactly what the built channel's SmartCollection will contain. |
2026-07-17 | link |
sched.autotune-per-channel-overrides |
Auto-Tune per-channel overrides reuse the Channel Builder's advanced-options DTO verbatim; per-source weights and bug-colour logo are deferred to #425. | 2026-07-17 | link |
sched.autotune-per-source-weights |
Auto-Tune per-source rotation weights and query corrections are supplied at bulk-create time via #70's MultiCollection/SmartCollection machinery, not a post-hoc PUT. | 2026-07-18 | link |
sched.clock-padding-existing |
Clock-boundary padding already exists via FillerPreset's FillerMode.Pad (Classic) and pad_to_next/pad_until (Sequential/YAML); #77 is closed as verified+documented, not built new, with a one-click per-channel toggle deferred behind the #388 design-system epic. |
2026-07-17 | link |
sched.clock-padding-schedule-toggle |
A ProgramSchedule.PadToNearestMinute (nullable int; null = off) makes the Classic builder pad every content item up to the next N-minute clock boundary without a hand-wired Pad FillerPreset, by reusing the existing per-content-item Pad path in PlayoutModeSchedulerBase.AddFiller. It extends — does not supersede — sched.clock-padding-existing (#77/#388). |
2026-07-22 | link |
sched.playbackorder-support-matrix |
Every build-time dispatch site logs a loud (non-fatal) warning on an unsupported PlaybackOrder, and a declared PlaybackOrderSupport matrix + partition tripwire test makes adding a new order safe by construction. |
2026-07-18 | link |
sched.reshuffle-scoped-reset |
POST /api/v1/playouts/{id}/reshuffle runs ErasePlayoutHistory (reseeds Playout.Seed + clears anchors/rerun-history) then enqueues a scoped Reset build, so reshuffle always reseeds — even for the non-Classic kinds Reset alone wouldn't reseed; Playout.Seed is surfaced on list/detail DTOs as visible confirmation. |
2026-07-16 | link |
sched.seasonal-scheduling-existing |
Seasonal/date-conditional scheduling already ships first-class via IAlternateScheduleItem (Classic ProgramScheduleAlternate, Block PlayoutTemplate) evaluated by AlternateScheduleSelector.GetScheduleForDate (first match in Index order, catch-all last); #73 is closed as already-implemented with a docs-only "seasonal/holiday" recipe added, not new code. |
2026-07-17 | link |
sched.shuffle-source-builder |
Shuffle-source construction moves to a static, DI-free ShuffleSourceBuilder (a shared seam, not a service) so Classic and Playlist stop cross-engine reaching into PlayoutBuilder statics; a unified Classic+Playlist enumerator factory is explicitly rejected as a god-factory. Block/Scripted/YAML duplication is left alone, deferred to a follow-up gated on #381. |
2026-07-17 | link |
sched.weighted-shuffle |
Fair-share/weighted airtime distribution ships as one new PlaybackOrder.WeightedShuffle = 9 order (equal weights = fair-share), not a retrofit of ShuffleInOrder (which only anti-clumps, since its padding spacers emit nothing) and not a separate orthogonal "distribution" setting; weights live on MultiCollectionItem/MultiCollectionSmartItem (DB default 1, dual-provider migration), bounded at write (1..1000) and clamped again in the enumerator, and the write path rejects WeightedShuffle at every dispatch site that doesn't handle it rather than let it silently degrade to unweighted random. |
2026-07-17 | link |
sched.weightedshuffle-editor |
WeightedShuffle per-source weights are edited on the multi-collection editor (property of the MultiCollection), while the WeightedShuffle order itself is offered only on classic MultiCollection schedule items; fair-share is a "reset weights to 1" action, not a stored mode. | 2026-07-19 | link |
scheduling.ondemand-guide-refresh-on-thaw |
When PlayoutTimeShifter.TimeShift slides an on-demand playout's materialized timeline forward on tune-in, it reports the channel numbers whose cached guide is now stale — the shifted channel plus any channels that mirror it — and TimeShiftOnDemandPlayoutHandler enqueues a RefreshChannelData for each, so every affected cached XMLTV fragment is regenerated from the just-shifted PlayoutItem rows. The guide and playback both read the same stored PlayoutItem.Start/Finish, but the guide is served from a cached projection — rewriting the rows without rebuilding the cache would leave the guide advertising a stale timeline. |
2026-07-21 | link |
security.artwork-content-type-sniff |
Artwork content type is always derived from the stored bytes (never the client-declared value or a ?contentType= query param) at both upload and serve, clamped to an image allow-list, closing the unauthenticated stored-XSS chain; Kestrel MaxRequestBodySize bounds upload DoS. |
2026-07-12 | link |
security.baseline-response-headers |
SecurityHeadersMiddleware, registered first in the pipeline, sets X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and Referrer-Policy: strict-origin-when-cross-origin on every response (CSP/HSTS deliberately deferred); API-key comparison is constant-time and playout pagination is clamped. |
2026-07-11 | link |
security.blazor-removal-auth-posture |
Removing the Blazor UI's OIDC-challenged surface exposes nothing a user couldn't already reach via the already-open /app SPA (open since phase (a)); real SPA/API authentication is deliberately deferred to #197, and the removal PR must preserve ConditionalIptvAuthorizeFilter, ApiKeyAuthorizationFilter, and JwtHelper access_token support. |
2026-07-11 | link |
security.contract-freeze-honesty |
The OpenAPI doc's declared security/401 scheme is generated from the same ApiKeyAuthorizationFilter.EndpointRequiresKey predicate the runtime enforces (so declared auth can't drift from enforced auth), every /api/* action returns a ResponseModel (no raw Application VMs), and Channel REST resources are keyed by immutable Id, never mutable Number. |
2026-07-12 | link |
security.corp-same-origin |
SecurityHeadersMiddleware sends Cross-Origin-Resource-Policy: same-origin on every response including /docs//openapi, blocking cross-origin no-cors embedding without affecting allowed CORS-mode fetches or server-side Jellyfin /iptv/* requests. |
2026-07-13 | link |
security.csp-permissions-policy |
SecurityHeadersMiddleware sends an enforcing (not report-only) Content-Security-Policy (no unsafe-inline/unsafe-eval; the one inline theme-bootstrap script allow-listed by hash) and a deny-all Permissions-Policy on the SPA//api//artwork//iptv; /docs and /openapi keep only the baseline headers, excluded from CSP because Scalar needs inline bootstrap. |
2026-07-12 | link |
security.fail-closed-api-auth |
Every mutating /api request requires X-Api-Key (no open mode); reads are gated by Api:RequireKeyForReads (default true) OR [RequiresApiKey] on sensitive controllers; CORS is an exact-origin allowlist (ApiCors); ForwardedHeaders trust stays configurable but defaults to trust-all-with-warning. |
2026-07-12 | link |
security.iptv-access-token-transport |
The /iptv ?access_token= value is percent-encoded (Uri.EscapeDataString) everywhere it is interpolated into an M3U/HLS/XMLTV URL (XMLTV additionally XML-escapes the encoded value), so a structural character can't malform the manifest or guide; Serilog logs a scrubbed request path (access_token → *** via IncludeQueryInRequestPath = false + a RequestPathScrubbed enricher), so a 5xx/Debug /iptv request never writes the token; and every dynamic token-bearing /iptv manifest (channels.m3u, xmltv.xml, the HLS multi-variant/media playlists) returns Cache-Control: private, no-store. |
2026-07-23 | link |
security.iptv-browser-token |
Under a JWT-enabled deployment (JWT:IssuerSigningKey set), the browser SPA obtains a short-lived, globally-scoped /iptv/* access token from an authenticated GET /api/v1/auth/iptv-token and appends it as ?access_token=; the endpoint answers 204 when JWT is disabled (nothing to mint). Lifetime defaults to 60 min, configurable via JWT:BrowserTokenLifetimeMinutes. |
2026-07-22 | link |
security.session-auth-dual-credential |
ApiAuthorizationFilter accepts a request when a valid X-Api-Key matches OR the principal is an authenticated session (cookie ctv-session, HttpOnly/SameSite=Lax); session-authenticated mutations require the presence-only X-CSRF header or are rejected 403. This narrows the OIDC-inert sub-claim of security.blazor-removal-auth-posture (#206) — the rest of that record's auth-surface enumeration still holds. |
2026-07-12 | link |
security.session-cutover-postify |
The browser SPA authenticates cookie-only (no more X-Api-Key from web/); the machine key is repurposed to external/MCP-only via GET /api/auth/machine-key; every side-effecting GET/HEAD under /api is converted to POST so the existing CSRF gate covers it (standing rule: never add a side-effecting GET/HEAD under /api). |
2026-07-12 | link |
session.local-code-intelligence |
C# and TypeScript find-all-references are available again; brief delegated agents to the csharp-lsp MCP tools (csharp_references, csharp_diagnostics, …) rather than the LSP tool, which no dispatched subagent has been observed to resolve (Claude Code 2.1.232, agent types general-purpose and Explore, 2026-08-14). Preconditions are machine-local — env.DOTNET_ROOT in .claude/settings.local.json and a root node_modules/typescript link — and checkable with scripts/check-local-lsp.sh. |
2026-08-14 | link |
session.shared-checkout-refresh |
Session end runs scripts/refresh-shared-checkout.sh, which fast-forwards /Users/timothy/ersatztv to origin/main (and reinstalls web/node_modules when the lockfile moved), refusing to touch anything unless that tree is on a clean, non-ahead main. |
2026-07-21 | link |
spa.add-to-layer |
All add-to-collection/playlist/schedule affordances share one component layer at web/src/media/addTo/; multi-select is an explicit screen-level toggle, and the per-card menu offers schedule only for the server-validated kinds. |
2026-07-10 | link |
spa.app-shell-extraction |
App.tsx is only the composition root over web/src/app/routes.tsx (stable route-object identity), app/AppShell.tsx (shell chrome), and app/ScreenContent.tsx (exhaustive screen dispatch); primary actions are one explicit PrimaryActionProvider registration per screen, replacing the old global ctv:primary-action window event. |
2026-07-15 | link |
spa.autotune-detailpanel-slideover |
The Auto-Tune DetailPanel SPA is a reusable SlideOver primitive sharing useOverlayBehavior with Dialog, plus a shared advanced-options model extracted from ChannelBuilder; decorative panes without backend support are dropped. |
2026-07-18 | link |
spa.channel-editor-create-logo |
Bare-channel create is a "New blank channel" action on the channels list (reusing Blazor's add-mode defaults) that navigates into the full editor, and an external logo URL always wins over an uploaded logo, matching ChannelEditViewModel precedence. |
2026-07-11 | link |
spa.channel-renumber-prompt |
Channel renumbering uses a sequential prompt()-driven "Renumber" action instead of drag-to-reorder. |
2026-07-09 | link |
spa.channels-screen-extraction |
The Channels domain is a single-file zero-prop screen (web/src/screens/ChannelsScreen.tsx) with a colocated test file and no sibling helper directory, since its pure logic is too small (~30 lines) to justify a separate business-rule layer like Schedules' itemRules.ts. |
2026-07-11 | link |
spa.collection-custom-order-ui |
Collection custom ordering uses per-row Move up/Move down buttons (not drag) and is offered for any manual collection with custom order enabled, not just movies-only. | 2026-07-09 | link |
spa.datetime-local-input |
The channel-mode date/time input uses a native <input type="datetime-local"> instead of free-text Chronic natural-language parsing. |
2026-07-09 | link |
spa.deco-templates-table |
The deco-templates editor also renders its day/deco assignment as a table, extending (not replacing) the templates-editor-table convention. | 2026-07-09 | link |
spa.download-sample-gate |
The SPA disables both Download Media Sample and Download Results while a troubleshooting session is starting/running (Blazor only gated Download Results). | 2026-07-09 | link |
spa.field-progressive-disclosure |
A consequential settings field explains itself through one shared FieldHelp icon trigger beside the field name — never the label itself, never a widened Tooltip — with the paragraph declared as const in the screen's own FIELD_HELP record and the panel portalled to document.body. |
2026-08-26 | link |
spa.legacy-redirect-matcher |
LegacyUiRedirects.TryGetRedirect is a two-tier matcher — an exact OrdinalIgnoreCase Map (Tier 1) then an ordered segment-template pattern list (Tier 2, first-match-wins) — collision-free by construction, with a guard invariant that no rule may prefix-match /api, /artwork, /docs, /openapi, /iptv, /app, or /media/sources. |
2026-07-11 | link |
spa.library-pickers-resolve-by-search |
A picker over a media-library table (Episode/Song/Image/Movie/MusicVideo/TelevisionShow/TelevisionSeason/Artist/OtherVideo/RemoteStream) resolves its options by SEARCH — a debounced SearchPicker calling searchLibraryPickerOptions, which issues at most ONE getLibraryBrowseItems request per settled query, bounded to LIBRARY_PICKER_RESULTS (25) rows — CLAMPED inside the helper, not merely defaulted — and gated on LIBRARY_PICKER_MIN_QUERY (2) characters. It list-loads NOTHING on mount or on a type switch, so there is no truncation to surface and no truncation hint. The typed text is COMPILED (titleContainsQuery → title:*<escaped>*), never forwarded raw. The current selection renders from the OWNING RECORD, not from the result set (selectedName on a rerun collection / playlist item; a single by-id detail read — getShow/getSeason/getArtist — for a filler preset, which stores only the id), and an edit draft is INITIALIZED ONCE from the detail read — never seeded from the list row, never reconciled against a late response — with the form withheld until it lands, the editor failing CLOSED when the response carries no USABLE concurrency token — absent, empty and whitespace-only ETags are ONE case, normalized in one place, so a PUT without If-Match is unreachable, and a deadline plus a route back so a hung request cannot strand it. An id NEVER travels without its namespace: search results are cached against (source, query) and list-backed options carry the type they were loaded for, so no id from one type can be offered under another; and every id entering editor state — search result, list-backed option, or a selection restored from a detail read — passes ONE shared isSelectionId (int32) predicate at that boundary, an unbindable id being treated as ABSENT rather than coerced. Conflicts are detected at SAVE time via If-Match -> 412 -> Reload, and Reload simply drops the draft back to null and re-runs the same initialize-once load, so the form is unmounted while the replacement is in flight; an asynchronously-resolved name is keyed to the id it was resolved for and never overwrites a label naming a different id. The typeahead implements the full ARIA combobox keyboard contract, because it replaces a natively keyboard-operable <select>. The other half of the superseded record is UNCHANGED: bounded-by-construction admin lists (collections, multi-collections, smart collections, playlists) still page to completeness via loadAllPages and still report complete/hint: incomplete. Server-side caps are not raised — this is a web-only change. |
2026-07-26 | link |
spa.logs-page-size-local |
The Logs page rows-per-page preference is stored in window.localStorage (ctv-logs-page-size), not a server ConfigElement. |
2026-07-11 | link |
spa.playback-troubleshoot-poll |
The playback-troubleshooting screen reports FFmpeg completion by polling GET /api/troubleshoot/playback/status (~2s) rather than a server push channel. |
2026-07-09 | link |
spa.playout-reset-button |
The SPA keeps a single Reset action (server picks the default build mode) and drops Blazor's separate "Schedule reset" button since its capability already exists via the playout's Edit-details flow. | 2026-07-09 | link |
spa.playouts-screen-extraction |
The Playouts domain (including its unguarded PlayoutsRouteScreen route wrapper with local pathname/popstate state) moved as one unit into web/src/screens/PlayoutsScreen.tsx, keeping its screen-specific sub-path route ownership colocated with the base screen; a pure structural move with no API/route/CSS/behavior change. |
2026-07-14 | link |
spa.rulebuilder-nesting |
The visual rule builder's Group nests recursively to a single shared cap, MAX_GROUP_DEPTH (types.ts, currently 5, root group = depth 0) — read by the UI's "Add group" gate, parse.ts and the round-trip property-test generator alike; everything else about the builder is unchanged from #176 (compile-only closed Lucene subset over the stored query string, no stored rule AST, field vocabulary from GET /api/v1/search/fields). |
2026-07-25 | link |
spa.schedules-editor-draft-save |
The schedules SPA editor mutates a local draft and flushes one explicit Save (PUT /api/schedules/{id}/items) instead of instant-persisting each action; Copy deep-copies all source references (fixing a Blazor omission); the shuffled-schedule GET's EnforceProperties lossy normalization is preserved and mirrored in the SPA's option lists. |
2026-07-11 | link |
spa.sidebar-collapsible-accordions |
The shell sidebar's collapse + nav-group-accordion state persists under two hyphenated ctv-sidebar-* localStorage keys (matching the repo's ctv- convention, not the prototype's dotted names); labeled groups default-collapsed. |
2026-07-18 | link |
spa.spa-rebuild-decision |
The UI is a full React SPA (ChicoryTV) rebuild over the REST API, not a Blazor Server reskin. | 2026-06 | link |
spa.templates-editor-table |
The SPA templates editor renders day/block assignment as a table, not Blazor's drag-and-drop calendar grid — an accepted, deliberate parity deviation. | 2026-07 | link |
spa.topbar-primary-action |
The TopBar's primary-action "+" button renders only when the active route declares a non-empty primaryAction, is wired (via a shared usePrimaryAction hook) only on single-unambiguous-create-flow list screens, and is dropped everywhere else rather than left as a dead/no-op button. |
2026-07-12 | link |
spa.yaml-validator-textarea |
The YAML playout validator takes pasted YAML via a <textarea>, not a server-side file path, since the SPA has no filesystem access. |
2026-07-09 | link |
startup.parallel-orientation |
A fresh session runs two concurrent tracks at startup — Orientation (AGENTS.md/CLAUDE.md → docs/README.md task-signal map → the active decisions catalog docs/decisions/README.md) and, only when no issue is named, Selection (scripts/select-queue.sh N, deterministic live-Gitea ranking). A named issue skips Selection entirely. ersatztv#237, the closed pickup tracker this replaces, is reduced to a single archival breadcrumb and MUST NOT be read for live state. |
2026-07-21 | link |
testing.deny-path-at-production-config-value |
Where behaviour is gated by a configuration value, an environment variable or a credential, the test matrix covers every value the surface will actually meet — the setting ABSENT, the setting at its PRODUCTION value, and each explicit opt-out — and it asserts the DENY branch, not only the allow branch. A fixture that OMITS the field tests the default and nothing else, so a fail-open reachable only through the configured value stays invisible however many tests are green (#756: thirty of them were). Two corollaries carry most of the weight. FIRST, a hand-written test double that is HANDED the resolved flag proves the CONSUMER reacts to it and says nothing about the line that DERIVES it; if no test constructs the real provider, a mistyped configuration key or a flipped default is unobservable to the whole suite. SECOND, the dangerous cell is whichever one production occupies, which is not always the explicit one: when the shipped default IS the permissive branch the absent case is the production case (#280's null Api:WriteKey), and when the default is fail-closed the configured value is the one nothing has exercised. Enumerate the cells before deciding which to test; do not infer the risky one from which is easier to write. This rule is NOT mechanically enforced and deliberately so — deciding whether a given test used the production value is a string predicate over test source, the class this repo has withdrawn twice. |
2026-08-21 | link |
testing.e2e-cleanup-scope-by-pid |
An E2E harness or agent may only kill processes whose PIDs it captured at launch — capture the PID; whoever owns the lifecycle releases it from a trap ... EXIT INT TERM. Never pkill -f "dotnet ErsatzTV.dll" (or any pattern that can match a process this run did not start). A foreign listener is reported, not reaped. |
2026-07-25 | link |
testing.e2e-local-fresh-config-dir |
Always point scripts/e2e-local.sh at a fresh config dir — leftover channels/schedules/DB rows bleed state between runs and corrupt assertions. (The readiness-probe hang this record was originally written about was fixed in #533; the fresh-dir rule stands on state-bleed grounds alone.) |
2026-07-21 | link |
testing.enumerating-guard-identity-not-position |
A guard that cross-checks a hand-reviewed registry against call sites discovered across the whole repo must key each entry on properties INTRINSIC to the site — file, kind, and the value source text — and never on its absolute line or column. A registry keyed on position is a function of every other file in the repo, so a branch that never touches the guard can invalidate it; and because each PR is green against its own base, that failure is structurally invisible pre-merge and lands on main after review and after the merge gate. Dropping the position keeps every mutation the guard exists for — a NEW site, a REMOVED site and a CHANGED value each still fail, since each changes the identity multiset — and costs exactly ONE case, which must be stated rather than implied: a SAME-IDENTITY SUBSTITUTION within one file (delete a registered site, add a different unreviewed one with the same kind and value token, net-zero count) now passes. A REPORTED failure still prints the discovered line:column, because identity and diagnostics need not share a format. Comparison stays a MULTISET count rather than set membership, so two sites in one file sharing an identity must be discovered exactly that many times and a third occurrence still fails. A SCANNER test that asserts real AST positions against FIXED inline fixtures is the opposite case and keeps its line/column identity — it has no churn, because its input does not move. |
2026-07-27 | link |
testing.fix-ships-a-witnessed-red-test |
A commit claiming to fix something may carry a Proves: <pytest selector> trailer; when it does, scripts/prove-fix.sh must show that selector GREEN with the fix and RED with the code side reverted, and CI enforces it per-PR. The trailer is opt-in — an unproven commit is allowed — but a claimed proof that does not hold fails the build. |
2026-08-16 | link |
testing.full-replace-asserts-field-list |
Any path that writes a WHOLE entity or a WHOLE child collection — a PUT-replace handler, a hand-built request object, a test comparer standing in for one — derives its field list from the authoritative type and asserts SET EQUALITY against it, rather than enumerating the fields by hand. A hand-written list is correct on the day it is written and structurally unable to report the day it stops being: the field that drifts is the one nobody wrote a line for, so no amount of care in the existing lines can reach it. The failure is silent by construction — a full replace with a field omitted returns HTTP 200 and destroys that field's value (#754 drifted from a 28-property DTO by one and cleared it; the symptom arrived hours later as missing pixels). SECOND CLAUSE, separable from the first: where a replaced child row carries state keyed to its identity — progression, ordering, an enumerator position — the handler RECONCILES BY ID rather than delete-and-reinsert, because reinsertion silently resets state a client never asked to touch (#252: a schedule PUT reset fill-group progression; #500: a dedup fix became permanent data loss because the add filter and the remove filter used different keys, so the two halves must agree on the key). Delete-and-reinsert is acceptable ONLY where no such state exists, and that emptiness is a fact about today's schema that a later feature can silently invalidate — so record it where the handler is, dated, rather than leaving it to be re-derived. The canonical worked example is ToolCatalogTests.Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields, which reads the accepted fields from the generated OpenAPI document and compares both directions. ON THE SPA SIDE the same rule is enforced by the TYPE SYSTEM rather than by a test: a full-replace body is built as Complete<T> (web/src/api/completeRequest.ts), a mapped type that makes every member of a generated request type required, so a builder that omits one fails npm run typecheck. Annotate BOTH the API-wrapper parameter (so later callers inherit it) AND each construction site including every .map callback return type, because the excess-property check that catches a PHANTOM field fires only on a fresh literal in a contextually typed position and a generic .map callback is not one. Do not infer from "most builders already typecheck" that the gap is closed: a member is omittable exactly when it is absent from the schema required array in the ASP.NET-produced OpenAPI document, and two live cases (#807) sat unchecked inside a large majority of checked ones. |
2026-08-21 | link |
testing.guard-derives-population-from-source |
A guard that asserts a COMPLETENESS property enumerates its population from a machine-readable authoritative source — the enum, the generated OpenAPI document, the parsed workflow YAML, the provider list — and asserts SET EQUALITY in BOTH directions against it. It may not narrow that population with a filter, a Where, a grep or an early continue before the assertion, because a filter cannot see the member that is MISSING: the member whose absence is the defect is precisely the one the predicate excludes. A hand-written literal list of members is the same defect in slower motion — a filter frozen at authoring time, correct on the day it was written and unable to report the day it stopped being. Two boundaries bound the rule rather than weaken it. FIRST, filtering to select the SUBJECT of a PER-MEMBER property is legitimate and is not this defect: the excluded members satisfy the property vacuously, so the filtered walk and the whole walk assert the same thing (ToolCatalogTests.Every_Query_Parameter_Should_Be_A_Declared_Property filters to tools that declare query parameters, and a tool declaring none has nothing to check). The defect is filtering the population before a COMPLETENESS claim, which is what makes an absent member unrepresentable (#757 filtered on QueryParameters is {Count: > 0} and so could not see a tool that should have declared one and did not). SECOND, a population of VALUES always has an external authoritative source and this rule applies directly; a population of SITES IN CODE has no such list, needs find-all-references tooling, and is tracked separately in #777 — do not stretch a set-equality assertion over it. Distinguish the guard SCOPE (which subsystems it covers — a reviewed policy choice, legitimately hand-written) from the guard POPULATION (the members inside that scope — always derived). When the scope itself MIRRORS an authoritative source, the mirror needs its own equality check against that source, or the guard is complete within a scope that has silently gone stale. A DATED STALENESS MARKER does not discharge this and is no longer offered as an alternative (#787): a date records when someone last looked, so it goes stale in exactly the circumstance it exists to report, and nothing reddens when it does. The canonical worked example in this repo is ToolCatalogTests.Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters. When a scope mirrors an authoritative source across a CREDENTIAL BOUNDARY, split the invariant at that boundary rather than dating a comment: commit the mirror verbatim, derive the scope from it offline, and reconcile the mirror against the live source wherever a credential exists — MARKED_JOBS in scripts/tests/test_ci_dropped_step_guard.py was this repo's residual gap until #787 closed it that way. WHEN THE POPULATION IS FILES (#806), the authoritative source is the GIT INDEX and never a filesystem walk. A walk is not merely a weaker enumerator, it answers a question about the MACHINE rather than about the repo: it reports build output, generated shims and editor droppings, and it differs between CI and every checkout, so the same guard asserts a different population in each place. Derive with git ls-files, take direct children only unless a nested population is stated and wanted, and assert existence rather than filtering on it, because filtering is what makes a missing member unrepresentable. ONE BOUNDED EXCEPTION to that existence half (#819): a guard MAY filter on existence where the OTHER direction reports the on-disk hole — where a tracked path that never reached the walk is itself compared and NAMED — since what makes filtering dangerous is the absent member becoming unrepresentable, and a paired assertion represents it. That buys tolerance of a state the assert-existence form cannot distinguish, which matters where the population is large and edited continuously and an unstaged deletion is routine rather than remarkable. Filtering on existence WITHOUT that paired direction stays the original defect. AND, where a guard PROVES a predicate by restating it (#819), that restatement must be CLOSED FORM over its raw input: it may share NO helper, at ANY depth, with the predicate it checks, and the population it reads must be cross-checked against an INDEPENDENTLY derived list. Anything shared sits on both sides of the comparison and cancels, so a narrowing there shrinks both and passes — five review rounds each fixed one shared thing and left the next: the scope shared between the two sides being compared, then an example table, then a delegated sub-predicate, then a basename helper, then the population array itself. Where the population comes from a derivation you also own, cross-check it against a SECOND query of that source rather than restating anything — and accept that a derivation lying consistently in both outputs is answerable only by testing the derivation directly. This is an instantiation and not a blanket rewrite: the question per guard remains whether it makes a COMPLETENESS claim over TRACKED files, and a walk that assembles a fixture or selects the SUBJECT of a per-member property stays a walk with its reason written down. |
2026-08-13 | link |
testing.guard-ships-with-mutation-proof |
A guard is not considered tested because a test involving it passes. It ships with a MUTATION PROOF: remove or disarm THAT GUARD'S CLAUSE ALONE, and a NAMED test must go red. ONE NAMED EXCEPTION, with its limits, because the rule degenerates without it: where the guard IS a test (a checker enforcing a repo invariant, with no separate script behind it), disarming it makes it ABSENT rather than red, so the proof is the contrapositive — INTRODUCE THE DEFECT THE GUARD EXISTS TO CATCH into an isolated copy of the guarded artifact, and the named test must go red. That is a mutation of the guarded SYSTEM rather than of the assertion, and it is admissible ONLY for checker-guards and ONLY when the mutation was executed and witnessed. It is NOT a licence to grade an ordinary script-guard MUTATION for having a bad-input test: feeding a script an input its clause rejects is BEHAVIOUR-ONLY, which is what three rows were regraded for. A file-level grade under this exception covers the clause its cited case actually mutates, not every assertion that later lands in the same file. Three things this excludes, each of which has already shipped here as a green suite over a dead check. FIRST, a behavioural test — one that feeds the guard a good input and a bad input and checks it passes and fails — proves the guard REACTS, never that it is LOAD-BEARING; #685 had two guards on one condition where deleting either left the whole suite green while every behavioural test passed. SECOND, mutating the WHOLE FILE does not count (#510): a whole-file revert cannot show that a test reaches a particular clause, so the mutation must target the clause. THIRD, the guard being WIRED is not the guard RUNNING — #631's suite was invoked by no CI job, #751's step was dropped by the runner and the job reported success in 6s against a normal 14-17s, and #719's new logic was never connected to stdin. Every guard that DERIVES A POPULATION also carries an ANTI-VACUITY assertion, because the characteristic failure of a completeness check is reporting that it proved everything while its population was empty; a guard with no population has nothing for such an assertion to be about, and stating it universally reads as coverage the unproven rows do not have. Mechanical enforcement is possible for the BOOKKEEPING and not for the JUDGEMENT, and the split is the decision: docs/guard-inventory.md lists every guard file with its Kind, its Proof class (MUTATION/BEHAVIOUR-ONLY/NONE) and a file::function ref, and scripts/tests/test_guard_inventory.py derives the guard population from the GIT INDEX and the call sites (#806), asserts SET EQUALITY against the rows, and resolves every claimed ref to a real def. So a new guard cannot ship unclassified and a renamed test cannot leave a row silently claiming coverage. Whether a row claiming MUTATION is telling the truth is no longer left to review: testing.mutation-claims-are-executed (#790) requires each such row to carry a DECLARED clause mutation that is applied to an isolated copy of the repository on every run, with the row's own named test required to go red. |
2026-08-13 | link |
testing.hook-reports-its-own-execution |
Every script in .claude/hooks/ sources scripts/hook-fire-log.sh and calls etv_hook_fire_begin <its-own-name> <label> <capture|stream> as its FIRST act, before anything reads stdin. Two records are appended per invocation — a fire record on entry and an exit record carrying the exit status and the decision — to a session-scoped JSONL log. THE DECISION IS READ FROM WHAT THE HOOK ACTUALLY EMITTED, never declared by the hook author: Claude Code hooks (capture mode) always exit 0 and communicate by PRINTING JSON, so their stdout is diverted and replayed, and the recorded decision is parsed from those bytes; git hooks (stream mode) decide by EXIT CODE and their stdout is live progress text a human is watching, so it is not diverted and the decision is the status. That split is not a tuning knob — capturing a slow pre-push hook's output would hold it back until the end and read as a hang, and inferring a git hook's decision from absent JSON would put the report back into the guessing business this record exists to end. The population is DERIVED from .claude/hooks/*.sh by scripts/tests/test_hook_fire_log.py, so a new hook is uninstrumented-and-red rather than silently unobserved, and the report lists every hook that EXISTS rather than every hook that appears in the log — a report built from the log alone can only show hooks that fired, which makes the never-fired hook, the one finding worth having, invisible. THE INSTRUMENTATION MUST BE INVISIBLE TO THE HARNESS, and this is the load-bearing half: it sits in the stdin and stdout path of the most authoritative guards in the repo, so a differential test drives EVERY hook with and without it over a payload matrix and demands byte-equal stdout and equal exit status. It fails OPEN in exactly one direction — if the log cannot be written the hook behaves exactly as before — because observability that breaks a guard is worse than the blindness it replaces. Two mechanical traps are pinned by tests rather than left to care: stdout must be replayed from the FILE, since out=$(cat f) strips trailing newlines and delivers a guard's JSON one byte short with no parser anywhere to complain; and stdin must never be slurped when it is a TTY, because an interactive git commit hands its hooks a terminal and cat would block forever, hanging the commit the instrumentation was added to observe. |
2026-08-14 | link |
testing.live-e2e-prepush-timing |
Run live-E2E via scripts/e2e-local.sh before pushing a write-path or UI change, and exercise download endpoints with curl, never a browser tab. |
2026-07-21 | link |
testing.mutation-claims-are-executed |
A MUTATION row in docs/guard-inventory.md is not a statement that someone once witnessed a red. It carries a DECLARED clause mutation in scripts/tests/mutation_manifest.py, and scripts/tests/test_mutation_harness.py applies that mutation to an isolated copy of the repository on every run and requires the row's OWN named test to go red. The manifest and the MUTATION rows are compared for SET EQUALITY in both directions, so a row cannot claim the grade without a mutation and a mutation cannot outlive the grade it justifies. EXIT STATUS IS NOT THE VERDICT: each entry also declares the DIAGNOSTIC its red must carry, matched against pytest's exception output alone, because pytest reports a crashing test exactly as it reports a detecting one and a red for an unrelated reason is evidence about nothing. WHERE THE GUARD IS ITSELF A TEST, target may differ from guard and the exact-once check applies to the declared TARGET. Two shapes are admissible and the choice is not free. Where the guard's assertion IS the check — a completeness comparison against a Markdown inventory — the mutation goes into the guarded ARTIFACT, per testing.guard-ships-with-mutation-proof's checker-guard exception, because mutating such a checker's own POPULATION demonstrates a false POSITIVE while proving nothing about the detection the row claims. Where the guard is a test module wrapping a separately mutable DETECTOR or helper, the clause may be in that detector, since disarming it is a real clause disarm and the module's own assertion is what notices. THE MUTATION IS DECLARED, NEVER INFERRED: a harness that guessed which clause of a 90-line hook is the guard would manufacture the confident-but-empty coverage this exists to prevent, which is why testing.guard-ships-with-mutation-proof rejected a generic runner. Where a proof test already names its clause in source, the manifest reuses THAT string, so a retarget in either place is caught by the other. COARSENESS IS RECORDED, NOT HIDDEN: each entry is graded CLAUSE or DETECTOR, and a DETECTOR entry — one whose detector accumulates faults from independent arms, so disarming any single arm leaves its proof test green — must CARRY the finer mutation that survived, which is re-run every time and required to keep surviving. Guards that are not graded MUTATION each carry a STATED reason in that same manifest, keyed on the guard and compared for SET EQUALITY against the inventory's GUARD rows in both directions — so a new guard cannot arrive without someone writing what a proof would need, and a reason cannot outlive the row it is about. Keying the reason on the row's GRADE instead is tautological (a new guard inherits one and nobody looks at it) and a pinned COUNT moves only on net change; both were tried and are rejected. The sandbox is a real git repository built from git ls-files with working-tree content, never a filesystem walk. |
2026-08-22 | link |
testing.playwright-mcp-download-and-recovery |
In Playwright-MCP E2E, fetch file-download endpoints with curl — never a browser tab or window.open — and if browser tools stall repeatedly, pkill -f ms-playwright-mcp and drive a fresh session. |
2026-07-21 | link |
testing.scripted-playout-golden-deferred |
The PlayoutBuildGoldenTests in-memory golden net covers Sequential (YAML) as of #381. Scripted's end-to-end pipeline is excluded — ScriptedPlayoutBuilder runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can't pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling behavior those scripts drive lives entirely in the in-process SchedulingEngine (the ScriptedScheduleController is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to ContentEnumeratorBuilder and adds a direct regression net (ContentEnumeratorBuilderTests) over it. |
2026-07-22 | link |
testing.suite-isolated-from-production-hook-fire-log |
THE SUITE MUST NOT WRITE TO $HOME/.cache/ersatztv/hook-fire/, AND THAT PROPERTY IS ESTABLISHED STRUCTURALLY RATHER THAN OBSERVED. Two layers in scripts/tests/conftest.py, which close different routes and must both stay. FIRST, pytest_configure sets ETV_HOOK_FIRE_LOG_DIR BEFORE COLLECTION. An autouse fixture alone runs at test setup — after every module has been imported — so it cannot reach a module that snapshots {**os.environ} at import time (the ersatztv#785 defect, 58 leaked records per run) and cannot reach a MODULE- or SESSION-SCOPED FIXTURE at all; measured on the pre-change tree by instrumenting subprocess.Popen for a full run and asking which launches resolve to $HOME/.cache/ersatztv/hook-fire, 83 did — 37 during collection, 44 inside module- or session-scoped fixtures, and 2 from an environment built from scratch. INSTRUMENT Popen ALONE: subprocess.run, call and check_output all reach it, so a census wrapping run as well counts every launch twice and every figure doubles. STATE THE PREDICATE WITH THE COUNT: this one also UNDERCOUNTS the from-scratch route, because such an environment usually carries no HOME either and lands in the sink's OTHER default, which this predicate cannot see. The launches that carry no ETV_HOOK_FIRE_LOG_DIR and are built from scratch number 83 as well, and 81 of those carry no HOME — two different sets of the same size, which is precisely the shape a later reader reconciles wrongly. The per-test fixture stays, because a test that inspects its own log dir must not see another test's records. SECOND, a Popen wrapper fails EVERY launch that does not CARRY the isolated directory — the route left open when an environment is built FROM SCRATCH rather than derived from os.environ, where no snapshot is involved and import ordering cannot help. THE RULE IS "CARRIES AN ISOLATED DIR", NOT "IS NOT THE PRODUCTION ONE", and that is the difference between a guard and a guard-shaped hole: etv_hook_fire_log_dir has TWO default branches — $HOME/.cache/... and, when HOME is unset or empty, /tmp/.cache/... — so a check comparing against one resolved production path models one branch and waves the other through, and /tmp/.cache/ersatztv/hook-fire is just as shared, just as persistent and just as readable with hook-fire-log.sh report --dir. Measured: of the 83 from-scratch launches, 81 carry no HOME either. Requiring the variable covers both branches and any a future sink adds, and is independent of what HOME happens to be on the machine running the suite — which is also what keeps it from firing on legitimate launches where HOME is unset or is itself /tmp. The corollary is that every from-scratch environment in the suite must carry the variable, one line each. NO COUNT OF THOSE SITES IS KEPT HERE: it is a hand-written population literal that nothing derives, and the commit introducing this rule already falsified its own, by adding further construction sites in the tests that prove the rule. The guard itself is the enumerator; a missing site is a red, not a stale sentence. Its population is every launch that actually happens, derived at runtime, so a new suite is covered by existing rather than by remembering. NEVER OBSERVE THE PRODUCTION DIRECTORY TO PROVE THIS. The withdrawn guard snapshotted st_mtime_ns across it and required no change, which makes the oracle state every concurrent session on the machine writes: an unrelated session firing a hook inside the window failed the suite with a test run modified the production hook-fire log, an accusation about the suite when the writer was another process. Seen on three separate branches, green on every immediate re-run. The misattribution is the expensive part — it aims the next reader at the suite, and a guard that cries wolf gets waved through. THE RESOLVER IS A REIMPLEMENTATION AND FAILS OPEN, so it is differential-tested against etv_hook_fire_log_dir every run: a resolver that disagrees clears a launch the sink then points at the real log. Shell's :- treats an EMPTY value as absent, which dict.get(k, default) does not — that case is in the matrix because it is where a Python transliteration goes wrong. AND BIND A VIOLATION BEFORE ASSERTING ON IT: pytest rewrites an assert expression and prints the repr of every sub-expression, so assert isolation_violation(os.environ) is None dumps the environment — API keys included — into the failure output and from there into a CI log. |
2026-08-28 | link |
testing.troubleshoot-path-cannot-test-branding |
Verify logo/watermark/bug changes through a real channel playout — a green troubleshoot run proves nothing about branding. | 2026-07-21 | link |
testing.verification-code-needs-its-own-proof |
VERIFICATION CODE IS CODE UNDER TEST. The obligation in testing.guard-ships-with-mutation-proof attaches to whatever emits a PASS/FAIL somebody acts on — a smoke test, a timeout wrapper, an operator-run checker, the harness that applies the mutations — and NOT only to files sitting in the guard population or named like guards; every one of the five defects measured below was in code nobody had counted as a guard. THE DISCRIMINATOR IS WHETHER A PROOF TEST CAN DRIVE THE CONSTRUCT HERMETICALLY, NOT WHETHER CI RUNS IT, and the difference is load-bearing rather than pedantic: .claude/hooks/*.sh and .husky/pre-push are operator-run and CI never fires them AS HOOKS, yet several of them carry MUTATION rows — among them pretooluse-bom-guard.sh, pretooluse-worktree-guard.sh and .husky/pre-push — because a pytest proof drives them in a sandbox. A does-CI-run-it test would sort those already-proven guards into the exempt branch. (Most of the remaining hooks are graded NONE; that is unproven debt, not evidence for the other discriminator.) "IT NEEDS A REAL SERVER" IS AN EXEMPTION CLAIM AND IT IS USUALLY FALSE: scripts/mcp_smoke.py takes its config path and server name as POSITIONAL ARGUMENTS, so a test hands it a synthetic config in a tmpdir pointing at a stub responder and drives it with no gitignored .mcp.json and no language server, which is how it is proven in scripts/tests/test_mcp_smoke.py. That coverage is a SUBSET and no count of it is kept here: the file's own docstring enumerates the cases it drives and names what it does not, so there is a list rather than a number to keep in step with it. A checker is never excused because its CALLER cannot run in CI. WHERE THE PROOF LIVES: the checker itself needs NO docs/guard-inventory.md row, because that population is derived from workflow and hook call sites and a transitively-reached script is rejected as a phantom; the proof goes in an ordinary scripts/tests/test_*.py, which joins the population automatically and carries the row, while the manifest entry names THAT test as guard and the checker as target. That shape already exists for mutation_harness_lib.py and needs no change to any population. ONLY WHAT NO PROOF TEST CAN DRIVE HERMETICALLY falls back to a WITNESSED NEGATIVE CONTROL — the failure path executed once, the false-green form reproduced, both recorded in the PR, and its decay stated because nothing re-runs it. scripts/check-local-lsp.sh is the genuine case: it asserts developer-machine installs (a global csharp-ls, a host/fxr under DOTNET_ROOT, a root node_modules) that no runner has. STOP-AND-SUBTRACT, adopted here as this record's own threshold: when a review round's finding was CREATED by the previous round's fix and that happens TWICE CONSECUTIVELY, delete the layer generating them rather than guarding it. READING THE CONSTRUCT IS NOT EXECUTING IT: ShellCheck 0.11.0 flagged NEITHER of the two shell defects below, and each is indistinguishable from correct by reading. |
2026-08-28 | link |
testing.workflow-declares-its-own-job-metadata |
A population of workflow JOBS is declared by the workflow itself, one machine-readable marker per job under env:, and every set a guard compares is DERIVED from those markers rather than from a literal list in the checker. A missing marker and an unrecognised value are both HARD FAILURES, never a default: a scheme whose absent value reads as some safe class stops applying the moment somebody adds a job and forgets, and the job nobody remembered to mark is the job nobody reviewed. Two markers exist under this rule. CI_EXECUTION_CLASS (toolchain / bare-runner) replaced the TOOLCHAIN_JOBS and BARE_RUNNER_JOBS literals in test_ci_image_pin_population.py (#789). CI_JOB_ROLE (guard / report-only / none) is the population of workflow-job guards that docs/guard-inventory.md was blind to (#786), and it is decided by what the job PRODUCES: a guard job output is a VERDICT, a none job output is an ARTIFACT, and a red in a none job means the build did not work rather than that an invariant was violated. docker-build.yml::build publishes an image and smoke-tests it and is none. Which jobs are none is read off the markers and is deliberately NOT enumerated here — a list in prose is a second copy of the workflow that rots on the next job added, and nothing checks it. TWO READINGS ARE REJECTED and recorded so neither returns. Anything-that-can-fail makes every job a guard and the table distinguishes nothing. And the more principled-sounding repository-versus-product split is worse: it puts test and migrations outside the population, and those are the two REQUIRED status contexts on main, precisely where a failure to fire is fail-OPEN against branch protection and precisely the gap #786 exists to close. THE VALUE IS report-only AND NOT advisory ON PURPOSE: docs/ci-cd.md already calls functional-e2e an advisory job in a different sense — not a required context, though it can certainly fail — so reusing the word would make a reader infer the wrong marker for exactly the job whose marker is guard. WHY THE MARKER BEATS THE LITERAL it replaced, since the literal had a real justification: set equality between two DERIVED sets is blind to a member leaving both at once, so a job that loses its container: block leaves the declared and the pinned set together and the comparison stays balanced. The population therefore needs an anchor that does not move with the thing it guards, and a reviewed literal was the only one available. The marker is that anchor and is strictly better placed: it lives NEXT TO the job, so a reviewer of that job reaches it, and it moves with the job when the job is renamed. THE COST IS ADJACENCY and must be paid explicitly rather than argued away — a literal in a distant file survives a careless workflow edit, whereas a marker a handful of lines from the container: block can be deleted along with it in one plausible slip. So a marker scheme is not sufficient on its own: it ships with a THIRD derivation independent of both the marker and the thing it describes. For the toolchain class that is jobs_whose_steps_need_the_toolchain, which reads the job OWN step bodies for tools present only in the CI image, and it is the only one of the three that can see the failure #789 was filed for — a .NET-dependent step MOVED into a bare-runner job, where no set changes and every equality stays balanced. That third check is NECESSARY-CONDITION ONLY and says so: step text cannot see a tool reached only through a script, so visible use implies the declaration and the converse is not asserted. MEASURED rather than asserted — the blind-spot set is EMPTY today, all five declared toolchain jobs being detected directly. AND THE RESIDUAL IS REAL: all three checks fail together under one edit that drops the container block, flips the marker AND moves the invocation into a script. What bounds it is the failure MODE — the job then dies on a missing binary, loudly, where the original defect would send a REQUIRED check green on the bare runner. A silent pass traded for a noisy crash, not a hole closed. Filtering to the jobs that visibly invoke a tool is legitimate subject selection for a PER-MEMBER property under testing.guard-derives-population-from-source, not a filtered completeness claim; the completeness claims are that every job declares a marker and that the two sets match both ways. WHAT THIS DOES NOT DO, stated because the checks read stronger than they are: it proves every job was CLASSIFIED by someone and that the table and the workflow agree, never that the classification is CORRECT, and an inline YAML assertion cannot be proven to work without running the job, so such a row carries Proof: NONE unless a pytest genuinely covers PART of it — ci-image-pin and set-verdict-status each cite one, and each row says which part, because a proof reference that covers a fraction of a job must not read as covering the job. |
2026-08-28 | link |
Review due
Active records that assert facts about the outside world and carry a stale-after date.
Once that date passes, re-confirm the fact and either extend the date or supersede the
record. Sorted soonest-first.
| Stale after | Key | Record |
|---|---|---|
| 2027-01-15 | ci.runner-placement |
link |
| 2027-02-15 | ci.infra-shaped-red-under-load |
link |
| 2027-02-28 | ci.workflow-dispatch-ref-unrestricted |
link |
| 2027-03-15 | ci.peak-anon-measurement |
link |