CI Quality Gates — SSOT
Every blocking gate in .github/workflows/ci.yml, what it enforces, and how its
baseline moves. GOVERNANCE.md points here; this doc owns the detail.
The gates
| Gate | Command | Baseline / threshold | Direction |
|---|---|---|---|
| ruff lint | uv run ruff check . |
0 findings; burndown ignores in pyproject.toml (SIM105, SIM102, E741) |
ignore list may only shrink |
| ruff format | uv run ruff format --check . |
exact | — |
| Complexity | part of ruff check — C901 (mccabe ≤20), PLR0912 (branches ≤24), PLR0913 (args ≤10), PLR0915 (statements ≤100) |
per-file baseline in pyproject.toml per-file-ignores (101 violations / 40 files, 2026-08-08) |
baseline may only shrink; never add a file |
| mypy ratchet | uv run python src/scripts/mypy_ratchet.py |
error count ≤ BASELINE in the script (1100, 2026-08-11 — kernel source only, /tests/ excluded) |
count may only fall; lower BASELINE when you fix errors |
| mypy fatal codes | same command | FATAL_CODES in the script — 0 occurrences, over a wider scope than the count baseline |
zero-tolerance; a code leaves the set only with a recorded exception |
| Tests + coverage | make coverage |
fail_under in pyproject.toml (62; measured 63) |
ratchet toward 70 → 80 |
| Slow suite (nightly) | make test-slow + the graph phantom gate, on the schedule trigger only |
0 failures; phantom count ≤ baseline | surfaced, not gating — CI Pass emits a warning; see the order-fragility note below |
| diff-cover (PRs only) | diff-cover coverage.xml --fail-under 80 |
80% on changed lines | fixed — see the scope note below |
| File-size ratchet | tests/test_file_size_budget.py |
SOFT_LIMIT = 500 with three recorded BASELINE exceptions (2026-08-11) |
each entry may only fall; a file outside BASELINE may never cross SOFT_LIMIT |
| shellcheck | shellcheck -S warning src/core/hooks/*.sh src/core/scripts/*.sh |
0 warnings | fixed |
| docs-lint | make docs-lint |
0 findings | fixed |
| CodeQL / dependency-review | GitHub-native | high severity | fixed |
Write-time counterparts (the same standards, earlier)
A CI gate tells you at merge time; a hook tells you before the edit lands. Two of the standards above have a write-time half so the feedback is not a 20-minute round trip:
| Standard | Write-time | Merge-time |
|---|---|---|
| File-size budget (500 backstop, 400 warn) | block-bad-patterns.sh — BLOCKs a Write that authors a file over 500, warns from 400 and on an Edit that grows one |
tests/test_file_size_budget.py per-file ratchet |
| Whole-tree budget (consumers) | — | make check-file-size → src/core/scripts/check_file_size.py |
All three halves now run at 500. They were deliberately split for a
while — write-time and the consumer script read COS_MAX_FILE_LINES (default
500) / COS_WARN_FILE_LINES (default 400), while the merge-time ratchet sat at
SOFT_LIMIT = 800 because 120 files in this repo were already over 500 and
dropping the number would have meant adding ~100 BASELINE entries, which the
ratchet's own protocol forbids.
The burndown closed that gap the only way the protocol allows: by deleting
entries, never by widening BASELINE. Every tracked Python file outside the
exclusion prefixes is now under 500 except the three recorded exceptions below,
so SOFT_LIMIT is 500 and write-time and merge-time finally agree. A file that
crosses 500 from here is a new offender, not legacy debt — split it; do not add
a BASELINE key.
Split parity — prove the move was a move
uv run python src/scripts/check_split_parity.py <pre-split-ref> <old-path> <dir>
re-parses the pre-split module and every module in the post-split package, and
reports any function that vanished or whose body is no longer byte-identical.
Pass the directory, not a hand-written file list — naming files by hand
produces false VANISHED reports for functions that landed somewhere unlisted.
It runs in seconds where the equivalent suite takes minutes, and it catches the class of defect a suite cannot: an edit that rides along inside a "move" commit. Deliberate edits are reported too, which is the point — land them separately.
Why the mypy gate has two tiers
A count ratchet cannot tell a genuine new bug from noise. On 2026-08-10 a module
split dropped the return normalized line from a function annotated
-> dict[str, Any]; mypy reported Missing return statement [return] and the
gate still passed, because that one error sat inside a 4,482-error budget. The
shipped effect was silent: the function returned None, every hook-map
comparison became None != None, and cos doctor reported a stale adapter as
healthy. Only a test that asserted the FAIL severity caught it.
So the script enforces two things from one mypy run:
- Count — total ≤
BASELINE, overSCOPE(the three kernel packages the baseline was measured against). Legacy-debt pressure. - Fatal codes —
FATAL_CODESmust stay at zero, overFATAL_SCOPE, which is wider (src/cliandsrc/core/webincluded) because those packages carry no count baseline and were therefore ungated entirely. Each code in the set was measured at zero when added and names a bug class that a refactor actually produces:return(a droppedreturnon a value-returning function),call-arg(a moved function called with the old signature),used-before-def(a reordered module-level statement).
Adding a code to FATAL_CODES requires it to be at zero first — fix the
occurrences, then add it, in that order. Removing one requires a recorded
exception below.
The count tier measures source only; the fatal tier still reads tests
SCOPE is three package directories and each contains its own tests/, so the
count used to include every untyped pytest fixture in the repo — 3,205 of 4,605
errors, 70% of the gate. That made a volume gate hostage to test refactors:
splitting the oversized suites on 2026-08-11 copied each module's untyped
preamble into its siblings, mypy counted the same errors several times, and the
count rose 4490 → 4621 without a single new type defect. The fatal tier measured
zero on that same run — the tier that would have caught a real one.
COUNT_EXCLUDE = r"/tests/" now applies to the count run only (TASK-936). The
volume gate measures kernel source; FATAL_SCOPE is unchanged, so a genuine
return / call-arg / used-before-def bug in a test still fails the build.
Why import-not-found cannot reach zero (TASK-925)
The kernel is imported under two conventions that both work at runtime because
src/ and src/core/ are both on sys.path: from thinking_os.tools import …
(220 files) and from core.thinking_os.tools import … (172 sites, kept alive by
an explicit packages entry in pyproject.toml). mypy can only give a file one
module name, and src/core/__init__.py forces it to be the core.-prefixed
one — so the other spelling is unresolvable by construction. Pointing
MYPYPATH at src/core does not help; it makes every kernel file resolve twice
and mypy stops with Source file found twice under different module names.
Two consequences worth knowing before touching this:
- The
strict = trueoverrides are spelled twice, once per convention. The bare spelling alone bound to nothing in the crawl, so the strict promise did not reach the source it named. Binding it cost zero new errors. - The unresolvable names are silenced per-package (
board_os.*,graph_os.*,thinking_os.*) rather than one module at a time, so a module split no longer adds a line. The remaining flat spellings (from database import …) stay enumerated: mypy's*matches a whole dotted component, never a bare prefix.
Collapsing to one convention would delete the whole class, but it is a ~400-site import migration plus a packaging change — not a config fix.
Ratchet protocol (applies to every "may only shrink" baseline)
- Fix the underlying finding (refactor below threshold, type the module, split the file).
- Shrink the baseline in the same commit — delete the per-file-ignore entry,
lower
BASELINE(mypy count, or the file's line entry) / raisefail_under. - Never widen a baseline to land a change; the gate exists to make regressions loud. A deliberate exception needs a task + a line here explaining why.
Recorded exceptions:
-
2026-08-11 (TASK-933 → TASK-936): mypy count
BASELINEraised 4490 → 4621, the only rise on record. Cause: splitting the 41 oversized test suites copies each module's untyped pytest preamble into every sibling, so mypy counts the same errors several times. Evidence it is an artifact and not a regression:FATAL_CODESmeasured zero on the same CI run, and no source file changed in that commit range. The number is taken from the CI log of run 31525204367, never from a local mypy. Retired the same day by TASK-936 below. -
2026-08-11 (TASK-936): mypy count
BASELINElowered 4621 → 1100 by excluding/tests/from the count run. Local measurement 4605 → 1069; the baseline carries the usual CI headroom and is tightened to the exact CI number once a green run publishes it. This retires the rise above rather than inheriting it. -
2026-08-11 (TASK-928):
src/core/hooks/session-context.sh(729) stays whole. Its siblingcos-env.shsplit cleanly 1301 → 350 because it is 22 order- independent function definitions: they moved to four leaves, the facade resolves its own symlink back to the meta-repo before sourcing them (so a consumer gets the leaves the instant core changes, with nocos update), and a snapshot of everyCOS_*value plus a sha of all 22 function bodies came back identical apart from theCOS_HOOK_T0timestamp.session-context.shhas the opposite shape — one function and ~700 lines of order-dependent statements threading$INPUT,$SOURCEand the panel state through each other. Cutting it would meansource part1.sh; source part2.shat fixed positions: indirection with no independently testable boundary, which anti-overengineering sub-rule 6 forbids ("never carve arbitrary fragments just to satisfy a number"). A real split needs the emitter restructured around the card sections first — its own task, not a move. -
2026-08-10 (TASK-928): the
_mcp_reclaim.py(935) entry was deleted rather than lowered — the facade is 47 over_mcp_stranded(458),_mcp_reports(309),_mcp_pick(189) and_mcp_worklog(138), named for what they own rather than keeping "reclaim" over four unrelated concerns. The gate was a real reclaim cycle, not an import check: a seeded five-task board with two cards stranded under dead sessions, run against agit archiveof the pre-split tree — 34 keys covering reconcile, dry-run and live reclaim (which actually moves a card back to icebox), re-reclaim idempotence, pick, claim, daily, retro, WIP and work-log append, plus every private classifier. All identical;daily.yesterdaymatches as a set, its list order being a same-secondtransitioned_attie.board_os.mcp_tools— the only real consumer — exports the same 122 names. Two traps the parity guard could not see and ruff did: an unused-import sweep deleted a function-LOCALimport reinside_commits_referencing_batch(the guard caught that one as EDITED), andcos_task_dailycallscos_task_reclaimacross the new seam, which onlyF821surfaced. Silencing the repeated absolute-import blocks took mypy from 4,478 to 4,474. -
2026-08-10 (TASK-928): the
backends/sqlite_backend.py(1,052) entry was deleted rather than lowered — the facade is 31 and now declares onlySqliteBackend(_SqliteWriteMixin, _SqliteLinkMixin, _SqliteReadMixin). This one is a class, not a module of functions, so the split is by mixin over a_sqlite_connectionbase (204) that owns the write lock, the per-thread read pool, schema verification and the two row primitives; the write (294), link (249) and read (359) mixins each subclass it, which keepsself._connand friends typed and cost mypy nothing — the count held at 4,478. The gate was a differential against agit archiveof the pre-split tree over a seeded 24-node/5-edge graph: 43 keys coveringGraphBackendProtocol conformance, the fulldir(SqliteBackend)surface, every read/write/link method, both the caller-supplied and standalone (own-connection, WAL, migrations) constructor paths, and the two raising paths — all identical. The delivered CLI was then smoke-run against the live 69,796-node graph (graph-stats,graph-context,graph-references,graph-doctor). No SQL moved out of the backend package, so the tool layer stays backend-agnostic. -
2026-08-10 (TASK-928): the
tools/learning.py(1,061) entry was deleted rather than lowered — the facade is 136 over four new siblings (_learning_extract329,_learning_validate343,_learning_generalize209,_learning_suggest169) alongside the four that already existed. The gate here is the tool surface plus behaviour: the live 87-tool MCP registry with every name, description and parameter list is byte-identical,server.py --testexits 0, and a functional differential against agit archiveof the pre-split tree runs extract (three argument sets), suggest (three), generalize, both consolidation passes, the fourlearn_validateoutcomes andvalidate_surfaced_lessonsover one seeded corpus — 21 of 21 result keys identical, the 22nd being the module's incidental stdlib imports. One monkeypatch trap surfaced: five sites patchedtools.learning._read_session_id_for_validate, which stops reachinglearn_validateonce that function resolves the name from its own module; they now targettools._learning_validate. Silencing the flat-siblingimport-not-foundclass at each fallback import took mypy from 4,482 to 4,478 — the split paid for itself rather than costing. -
2026-08-10 (TASK-928): the
routes/board.py(1,086) entry was deleted rather than lowered — the facade is 89 over five parts (_board_shared,_board_presence,_board_autospawn,_board_git,_board_tasks,_board_views), largest 424. SameAPIRouter-in-the-leaf shapehub.pyneeded: the router lives in_board_shared, no part imports a route module, and all seven import orders register 16 routes under both thecore.web.*andweb.*identities. Gated by a differential against agit archiveof the pre-split tree — 24 live requests throughTestClient(every route plus the 400/404/traversal paths), byte-identical once the per-secondstatus_dwell_secondscounter is normalized. One monkeypatch trap surfaced:tests/test_hub_settings_auto_spawn.pypatched_auto_spawn_enabledon the board facade, which stops reaching_auto_spawn_safeonce that function resolves the name from its own module; the patch now targets_board_autospawn, verified by confirming the old target no longer binds. ThePLR0913per-file ignore moved fromboard.pyto_board_tasks.py(12-argboard_create) rather than being widened, and mypy held at 4,482. -
2026-08-10 (TASK-928): the
cognition.py(1,237),routes/hub.py(1,217),extractors/contracts.py(1,196) anddoctor_extras.py(1,121) entries were deleted rather than lowered — the facades are 81, 326, 310 and 85. Each gate was the runtime surface the file owns, captured before and after: the live 87-tool MCP registry with every name, description and parameter list plusserver.py --test; all 19/api/hubroutes plus ten live responses through a started Hub; the extractor over a pinned 1,270-file corpus plus a synthetic one covering all 25 scanner families, byte-identical; andcos doctor+cos doctor --tokensrun end to end through bothcosandpython -m cli.main, 63 checks with matching severities. Two traps surfaced.hub.pycould not keep theAPIRouterin the facade: withfrom .hub import routerin the part modules and the facade importing them back, importing any part module first raisedImportErroron a partially-initialised sibling — a real failure a-kselection reproduced. The router and thesys.pathbootstrap moved to the_hub_sharedleaf, and all five import orders now register 19 routes.tests/test_hub_init_route.pypatched_run_cos_initon the hub facade, which stops reaching the route once the route resolves the name from its own module; the patch now targets_hub_init_routes, verified by confirming the old target fails on the new layout. -
2026-08-10 (TASK-928):
src/cli/main.py(1,601 lines) was split into_cli_pathsplusinit_command,adopt_command,install_commandsandruntime_commands; the entry was deleted rather than lowered — the facade is 393 and now owns only thecligroup and its registrations. The blast radius here is command registration, so the gate was a full CLI-surface snapshot (every command, its help line, and every option's flags and defaults) diffed before and after: 154 commands, byte-identical. Two monkeypatch traps surfaced and were fixed at the patch site, not papered over. Both predate the split and were only made visible by it: the autouse_stub_initial_indexingfixture patchedcli.main._initial_doc_index, but the call site iscli._init_phase, so the stub never applied and everycos initin the suite ran the real doc + graph indexer; and six tests across three files reached the stdlib throughmain.os/main.shutil/main.subprocess, which worked only because the facade happened to import them. Both now patch the module that owns the call, which tooktests/test_cli.pyfrom 12m17s to 3m53s. -
2026-08-10 (TASK-928): the
code_python.py(1,454) andcode_go.py(1,422) file-size entries were deleted rather than lowered — the facades are 242 and 321. Both keepextract()and the whole pre-split public surface; the walkers live in leaves that import the uid module and never each other. Verified by a differential that runs the pre-split module and the facade over the same corpus in both parse modes and compares nodes, edges and parse errors byte-for-byte (Python: 158 sources x ast/tree-sitter; Go: 44 sources x tree-sitter/regex).code_go's overlay handle is now bound by assignment rather thanimport ... as, so importers get an explicit export and mypy holds at 4,482. -
2026-08-10 (TASK-928):
src/core/thinking_os/embeddings.py(943 lines) keeps itsBASELINEentry and is not split. It has clean cohesion seams (model config, encoding, similarity, storage, search, outbox/reindex) but no seam that survives its test contract:is_available,_get_modelandembed_textare patched withpatch.object(embeddings, …)in six suites, and every other section calls them —cosine_similarity_with_meta,upsert_embedding,search_similar,drain_outboxandreindex_alleach callis_available()directly. The module is deliberately written that way (see the comment atembed_text: "…so existing tests that patch it keep working"), so moving any caller to a sibling means the facade patch stops reaching it and the test silently exercises the real encoder. The same class of blocker aspr_commands.pybelow; the next attempt starts from the patch strategy, not from the module. -
2026-08-10 (TASK-928): the
_shared.pyfile-size entry (947) was deleted rather than lowered — the split dropped the facade to 398, underSOFT_LIMIT. The five_envelope_*siblings carried the flat-siblingimport-not-foundclass again; this time it was silenced precisely at each fallback import (# type: ignore[no-redef,import-not-found]) instead of via a bare-nameignore_missing_importsglob, which at the time appeared inert. Corrected 2026-08-11: the exact-name entries always worked; only the_tools_*entry was dead, because mypy's*matches a whole dotted component and never a prefix — so every split kept multiplying the class. Spelling the flat sibling names out dropped the local count 4,486 → 4,415. Typing the trimmers'dict/listparameters had earlier taken it to 4,482;BASELINEmoves only from a CI log, never a local run. -
2026-08-10 (TASK-927):
src/cli/pr_commands.py(2,024 lines) was split into a_pr_sharedleaf plus apr_reap_commandsmodule and then reverted. The split itself worked —cos pr/reap/healall smoke-ran and the command count held at 99 — but its test suite patches eleven private helpers directly on thepr_commandsmodule. Once a helper moves, a patch on the facade no longer reaches calls made from inside the sibling, so ~20 tests fail for reasons that have nothing to do with behaviour. Making them pass means either patching two namespaces per helper or rewriting the suite's patch strategy — a change to the tests' design that deserves its own task rather than riding a file move. The file stays on the backlog with this note so the next attempt starts from the test suite, not from the module. -
2026-08-10 (TASK-928): mypy BASELINE 4524 → 4500 and the
workflow.pyfile-size entry (964) deleted — the split dropped it to 422. The +2 the split first introduced were the flat-siblingimport-not-foundclass again, soboard_os.configand the threetransition_gates*names joinedignore_missing_importsrather than widening the count; that fix cleared 24 errors repo-wide. All five new_workflow_*siblings were added to the mypy strict list so the split does not silently drop the strict coverageboard_os.workflowhad. RuffPLR0915is no longer needed onworkflow.py(4 ignores → 3). Behaviour verified by a 20-scenario differential against the pre-split module comparing everyTransitionResultfield, the tasks row, thetask_status_historyentry, and the written frontmatter. -
2026-08-10 (TASK-928): mypy BASELINE 4540 → 4524 and the
code_php.pyfile-size entry (979) deleted rather than lowered — the split dropped it to 300, underSOFT_LIMIT. The ruffC901/PLR0915per-file ignore moved fromcode_php.pyto_php_symbols.py, the one function that earns it; the facade now passes with no ignore at all. Verified by a differential that runs the pre-split module and the facade over the same corpus in both parse modes and compares nodes, edges and parse errors exactly. -
2026-08-10 (TASK-927): mypy BASELINE 4599 → 4540, tightened not widened. Four more god-file splits pushed the count up +46 by repeating flat sibling imports and by the dual-identity
try/exceptguards reading asno-redef. Both are artifacts of the split mechanics, so they were fixed at the source — theignore_missing_importslist now covers every flat sibling name and each guard's fallback branch carries onetype: ignore[no-redef]. Net for the session: 4651 → 4540. -
2026-08-10 (TASK-926): mypy BASELINE 4687 → 4567 → 4599, a net fall of 88 across the session. The
ignore_missing_importsoverride for the flat sibling names removed 120import-not-founderrors; the try/except import guard indatabase.pythen made the relative branch resolvable, so mypy started type-checking through_db_migrationsinstead of treating it asAnyand surfaced 32 real errors it had been blind to. A rise that buys visibility is not the same as a rise that hides regressions — this one is the former. -
2026-08-10 (TASK-926):
src/core/thinking_os/_db_migrations.pystays at ~2,240 lines, over the 500 backstop, and is baselined in the file-size ratchet rather than split. It is the append-only schema ledger: Rule 9 freezes every entry the moment it ships, so the file has exactly one reason to change — appending. The only available cuts are by version range (arbitrary) or by subsystem (graph migrations sit at v12 and v28, so ordering breaks and reading the schema history becomes a four-file scan). Splitting here would produce exactly the incoherent fragments anti-overengineering.md sub-rule 6 forbids. -
2026-08-08 (TASK-920): mypy BASELINE 4599 → 4649. The mcp_tools/doctor splits relocated 166 existing errors under new module identities and the dual
board_os.*/core.board_os.*import paths double-count some of them; a pre/post error-list diff confirmed no new untyped code. The gate caught the +410 implicit-re-export regression first, which WAS fixed (__all__). -
2026-08-10 (TASK-925): mypy BASELINE 4651 → 4687. The server.py split moved code verbatim into seven
_tools_*siblings; each repeats the flat imports (from _server_runtime import …,from tools._shared import …) that mypy cannot resolve, so oneimport-not-foundbecame seven. No new untyped code — the delta is entirely[import-not-found]on modules that were already unresolvable from server.py. The root fix is amypy_paththat makes the flat-import convention resolvable; a naiveMYPYPATH=src:src/core:…produces a duplicate-module error (dispatcher.pyreachable two ways) and needsexplicit_package_bases, so it is tracked as its own task rather than bolted onto a refactor. -
2026-08-09 (TASK-921): mypy BASELINE 4649 → 4651. The gate caught a real regression from the new repair tests (wrong import path + unnarrowed
Optionals) and that WAS fixed; the residual +2 sits inside the local↔CI counting gap (local reports 4635 for the same tree), so the baseline is re-measured from CI. Always re-measure from a CI log — a laptop number will silently under-set the gate. The failure output now prints per-file counts so the next rise is diagnosable without a CI round-trip.
What each gate does NOT cover (scope honesty)
The gates are real, but two of them only ever see pull requests, and this repo
is trunk-based — the maintainer pushes straight to main:
- diff-cover ≥80% and dependency-review run on
pull_requestonly. In practice that means Dependabot and release-please PRs; a hand-written commit pushed tomainis never measured by either. Treat them as gates for external contributions, not as a guarantee over all code. Worse until 2026-08-10: diff-cover fetched the base branch with--depth=1, soorigin/<base>...HEADhad no merge base and the step crashed on every PR. The gate had never measured a single line — it failed closed, blocking the whole Dependabot queue. A gate that only ever fails is not a gate. - Branch protection requires
CI Passwithenforce_admins: false, which is what keeps trunk pushes working. For the maintainer the check is therefore post-push reporting, not a pre-merge block — a redmainis visible and must be fixed forward, not prevented. make docs-linthard-gates the link audit; its front-matter and staleness half is advisory locally and only strict-gated on changed docs in CI.- The nightly slow suite is order-fragile, so it reports rather than gates.
Measured 2026-08-09: two back-to-back
make test-slowruns on an identical tree failed on different sets — the second run cleared five fixed failures and surfaced two new ones (TestPersonaGoFiber) that had passed an hour earlier. Root cause class: tests inheriting ambientCOS_*env and shared scaffold state (issue #39). Until a run is reproducible,CI Passemits a warning instead of failing; gating on a flaky suite would just teach everyone to ignore a redmain.
External posture — the OpenSSF Scorecard (what it does and does not measure)
scorecard.yml publishes to https://api.scorecard.dev/projects/github.com/kouroshez/coding-os;
the README badge reads that API. The aggregate is a risk-weighted mean —
Critical checks weigh 10, High 7.5, Medium 5, Low 2.5, and a check scoring -1
(an internal error) is excluded from the mean entirely. Reproduced against the
2026-08-13 run: weighted sum 575 over weight 97.5 = 5.9, matching the API.
That arithmetic is the point: it says which fixes move the number and which cannot. Per-check impact on the aggregate, measured from the same run:
| Check | Was | Fix | Δ aggregate |
|---|---|---|---|
| Vulnerabilities (High) | 0 | 79 → 0 advisories; 72 of them were one stale spring-boot-starter-parent in a scaffold |
+0.77 |
| Maintained (High) | 0 | nothing to do — the repo is <90 days old and the check refuses to score it | +0.77, on its own, in time |
| Code-Review (High) | 0 | needs reviewed PRs — see below | +0.77 |
| Fuzzing (Medium) | 0 | fast-check property tests (the check reads Go/Haskell/JS-TS/Erlang/C#, not Python) |
+0.51 |
| Pinned-Dependencies (Medium) | 2 | 48 action refs pinned by SHA | +0.41 |
| Signed-Releases (High) | 8 | 8 is "signed"; 10 needs an *.intoto.jsonl provenance asset on the release — but see the correction below |
+0.15, five releases out |
| CII-Best-Practices (Low) | 0 | self-assessment at bestpractices.dev — passing 5, silver 7, gold 10 | +0.13 … +0.25 |
| SAST (Medium) | 8 | CodeQL must run on every commit, not most | +0.10 |
| Contributors (Low) | 0 | needs contributors from ≥3 companies — not reachable for a solo project | — |
Measured outcome (2026-08-13, commit b6704711): 5.9 → 7.4, and 7.6 at
e7330c39 once SAST reached 10. Vulnerabilities 0→10, Fuzzing 0→10,
Pinned-Dependencies 2→7. Pinned-Dependencies stops at 7 rather
than 10 because the remaining warnings are tests/golden/** snapshots of the
template scaffolds, install.sh, and the pip/npm commands. The scaffold
Dockerfiles are left on tags deliberately: a consumer who runs cos init
should not inherit a base-image digest that was already stale the day it shipped,
and they are the ones who pin when they productionise.
Signed-Releases: shipping provenance once does not move it (measured)
The prediction above — "8 until the next release publishes the asset" — was
wrong, and the way it was wrong is the useful part. v0.3.15 published a real
SLSA-v1 coding-os.intoto.jsonl whose subject digest matches the wheel on PyPI
byte for byte, and the check still reported 8. Its reason string says why:
5 out of the last 5 releases have a total of 6 signed artifacts.
Signed-Releases scores each of the last five releases (10 with provenance,
8 signed-only, 0 unsigned) and averages: (10+8+8+8+8)/5 = 8.4, floored to 8.
So it reaches 10 only after five consecutive releases each carry the asset —
around v0.3.19. Nothing to fix; the workflow is already correct. Verify a
release actually carried it rather than trusting the workflow's green tick:
gh release view v0.3.15 --json assets --jq '[.assets[].name]'
The generalisation: a Scorecard check that averages over a window cannot be moved by one commit, and reading a green publish job as "the score will rise" is the same mistake as reading a closed alert as "the finding was fixed" (§ Triaging a CodeQL alert).
Branch-Protection must stay unscored while the repo is solo trunk-based
The check currently errors (-1, rendered as ?) because the default
GITHUB_TOKEN cannot read classic branch-protection rules. The obvious fix —
hand scorecard-action a fine-grained PAT — lowers the aggregate. Scoring
is tiered and a tier must be satisfied in full before the next one counts:
Tier 1 (no force-push, no deletion) is met and is worth 3/10; Tier 2 requires at
least one review approval before merge, which trunk-based direct pushes do not
have. Entering the mean at 3 gives 597.5/105 = 5.69, i.e. −0.2 for doing the
"right" thing. Adding the PAT is correct only in the same change that starts
requiring PR review — and 6/10 is where it merely breaks even.
The tiers, verbatim from Scorecard's checks.md,
because the ordering is what makes the trade-off unavoidable:
| Tier | Worth | Requires |
|---|---|---|
| 1 | 3/10 | prevent force push · prevent branch deletion |
| 2 | 6/10 | ≥1 reviewer approval · PRs required · branch up to date · approval of most recent push |
| 3 | 8/10 | ≥1 status check before merging |
| 4 | 9/10 | ≥2 reviewers · CODEOWNERS review |
| 5 | 10/10 | dismiss stale approvals · include administrators |
main already satisfies Tier 1 and Tier 3 (allow_force_pushes=false,
allow_deletions=false, required_linear_history=true, required check CI Pass),
but Tier 3 pays nothing while Tier 2 is unmet — and Tier 2 opens with a reviewer
requirement one maintainer cannot supply. Hence 3/10, hence the −0.2.
enforce_admins also stays false, and not from laziness: with a required
status check, enabling it blocks direct pushes to main — a push arrives with
no check attached and is rejected. Turning it on would not harden trunk-based
development, it would end it.
Code-Review sits on the same fault line: it reads approvals over the last ~30
changesets, and this repo commits straight to main by design (Rule 23).
Two clauses of the official check description settle it:
If the project has only one contributor, or does not have enough reviewers to practically require that all contributions be reviewed […]
Review by bots, including bots powered by artificial intelligence / machine learning (AI/ML), do not count as code review.
The second is the one that matters here. An agent reviewing an agent's diff is explicitly not review — so no amount of agent diligence moves this check, and routing work through self-merged PRs to farm the metric would be scoring theatre. The one legitimate lever is Scorecard's own implicit review rule (the merger being a different person than the committer), which needs a second human.
Treat Code-Review, Contributors, and Maintained as facts about the project's shape; the honest ceiling while that shape holds is roughly 8.5, not 10.
What replaces review when review is impossible
A solo trunk repo cannot buy the second pair of eyes control, so it buys the controls that do not need one — and spends them on the step where a mistake is permanent. Reversibility, not blast radius, decides:
| Step | Undo | Gate |
|---|---|---|
| commit | git restore |
none needed |
push / merge to main |
git revert |
required check CI Pass |
| publish to PyPI | none — a yanked version can never be re-uploaded | pypi environment, required_reviewers |
The publish row is the one worth arguing about, and this repo argued both sides
in a single day. The pypi environment existed for Trusted Publishing but
carried protection_rules: [], so merging a release PR uploaded to PyPI with
nothing in between. A required_reviewers rule was added and proved by
execution — a dispatched publish of v0.3.16 was held at the gate and rejected,
and the job recorded steps: [], so no build, no signing and no upload ran.
It was then removed the same day, on the maintainer's call, and that reversal is the more useful record. The gate worked exactly as designed; the objection was not that it failed but that it charged an approval on every release of a one-maintainer project to prevent a mistake that had not occurred, while CI already gates the merge that mints the tag. Friction an operator has to clear on every routine pass is friction that eventually gets routed around — a gate disabled in annoyance protects less than a rule followed on purpose.
So the boundary now lives entirely in git-workflow.md: the agent asks before publishing; nothing in the platform stops it. Current state is verifiable in one call — expect an empty list here:
gh api repos/:owner/:repo/environments/pypi --jq '[.protection_rules[].type]'
If a future maintainer wants it back, the knobs are required_reviewers (a
click per release) or wait_timer (ships by itself, leaves a cancel window).
Keep prevent_self_review off either way: it forbids whoever triggered a
deployment from approving it, which on a solo repo means nobody can.
The 134 "code scanning alerts" are two populations
68 of them are the Scorecard SARIF upload re-reported as alerts
(PinnedDependenciesID, MaintainedID, CodeReviewID, …) — they clear when
the corresponding check score rises, and 61 were the unpinned actions. Only 66
were CodeQL findings about this codebase. Filter before triaging:
gh api "repos/kouroshez/coding-os/code-scanning/alerts?state=open" --paginate \
--jq '.[] | select(.rule.id | test("ID$") | not) | [.number, .rule.id] | @tsv'
Triaging a CodeQL alert: fix, or dismiss with the evidence
Two things make this repo's alert list misleading if read at face value.
CodeQL does not model this codebase's barriers. cognition.py validated
agent and session_id with _safe_seg before joining them into a path, and
server.py:220 does the textbook (root / sub).resolve().relative_to(root.resolve())
containment — both were still reported as py/path-injection. Likewise
usedforsecurity=False does not satisfy py/weak-sensitive-data-hashing:
it classifies the input as an id, so the alert simply reopened on the new
line. Adding a guard is therefore the right engineering move and frequently
will not clear the alert; the two outcomes are separate jobs.
Reachability is the question that decides fix-vs-dismiss. The Hub binds
127.0.0.1 and _CORS_ORIGINS is a fixed localhost allowlist, so a malicious
page the developer visits cannot reach these routes: a JSON PATCH/POST
triggers a CORS preflight that fails without an Access-Control-Allow-Origin.
That is what makes /registry/scan taking an arbitrary local path acceptable —
pointing coding-os at a directory on your own machine is the feature. It is
also why COS_WEB_CORS_ALLOW_ALL=1 must stay a debugging flag: setting it
converts that whole class from "by design" to "remotely exploitable".
So: fix where a guard is genuinely missing (the --skills / extra_skills
path was one — it reaches a path segment with no registry check), and dismiss
the rest naming the barrier or the design constraint, never as housekeeping.
A dismissal with no evidence is indistinguishable from not having looked.
Accepting an advisory that has no fix
src/templates/go-fiber/scaffold/src/backend/osv-scanner.toml is the worked
example: GO-2026-5932 covers golang.org/x/crypto/openpgp, which is
unmaintained by design and therefore has no fixed version to move to. The
scaffold does not import it — proven by osv-scanner --call-analysis=go
reporting called=false, not by inspection — so the ID is ignored with that
evidence and an ignoreUntil date that forces re-review. An ignore without a
recorded reason and an expiry is just a suppressed alert.
mypy promotion path
[tool.mypy] is lenient globally with per-package strict = true overrides
(thinking_os.tools.*, graph_os.backends.*, board_os.workflow). The ratchet
holds the total error count while packages are promoted one at a time: type a
package, add it to the strict list, lower BASELINE. mypy becomes a plain
zero-error gate when BASELINE reaches 0.
Local mirror
pre-commit runs ruff, ruff-format, and shellcheck on staged files — the fast
subset. mypy, coverage, and the ratchets run in CI and via their commands above.