Skip to content

fix(docs,ci): the service context file described the template repo, and three guards were blind - #89

Merged
DuqueOM merged 1 commit into
mainfrom
fix/service-context-and-dead-bridges
Sep 5, 2026
Merged

fix(docs,ci): the service context file described the template repo, and three guards were blind#89
DuqueOM merged 1 commit into
mainfrom
fix/service-context-and-dead-bridges

Conversation

@DuqueOM

@DuqueOM DuqueOM commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Closes the three items left open after #88. Each sat on top of a guard that could not see the thing it was guarding.

1. The service context file described the template repo

templates/service/CLAUDE.md ships into every scaffolded service and still described the pre-migration template repo:

  • a templates/ tree with cicd/, monitoring/ and common_utils/ — dissolved by ADR-030 in June;
  • bash templates/scripts/new-service.sh — telling the adopter to scaffold a service from inside their service;
  • pointers to examples/minimal, releases/, CHANGELOG.md, .cursor/rules/ — none of which exist in a generated service;
  • a surface claim of 18 rules + 26 skills + 18 workflows against a live 19 / 27 / 20.

Rewritten to the service's own layout and command set (make train, make drift-check, make audit-rules…), with the template-repo audit history replaced by an actionable Upstream template section.

Why nothing caught it: the path gate reads backticked spans, and this is an ASCII tree. Doc-coherence C4 does check surface counts — but only the root CLAUDE.md. The service copy is reconciled by the same script inside a generated service, i.e. after it has already shipped wrong. C4 now reconciles both, each against the agentic surface beside it.

2. make scaffold-update ran copier update unpinned — defaulting to main

The service Makefile ran:

copier update --trust --defaults

/scaffold-update was pinned in #71. The Makefile target — the other entry point to the same operation — was not.

Worse: it reused REF, which the ci-green target defines as REF ?= main. So a bare make scaffold-update updated the service from the moving development branch, not a release. Two targets sharing one variable with two different meanings (a git branch vs a template release tag).

Now TEMPLATE_REF, with no default:

$ make scaffold-update
TEMPLATE_REF is required. Usage: make scaffold-update TEMPLATE_REF=<template release tag>
exit 2   # and copier is never invoked

Why nothing caught it: check_adopter_scaffold_ref.py scans by SCAN_EXTENSIONS, and a Makefile has no extension. Its own comment warns:

a guard whose coverage is a literal list is only ever as complete as the moment someone last remembered to edit it, which is exactly the defect class this repo keeps finding

Scoping by extension was the same mistake wearing a different hat. Extensionless build files are now scanned; the widened guard caught the unpinned target on its first run.

The guard's failure message also described a catastrophe ADR-045 already removed (the 627→435-file v1.x downgrade). Corrected to the current reality: unpinned means jumping to a release nobody chose, and the catastrophic form is prevented by the tag namespace staying clean rather than by the command.

3. Four broken sys.path bridges, and a contract test that self-disabled

Three bridges in templates/service/tests/ probed directories where common_utils has not lived since ADR-030:

File Probed common_utils there?
conftest.py parents[2]templates/ no
test_memory_redaction.py parents[3] + "templates" no
test_memory_contracts.py parents[3] + "templates" no

Dead safety nets — masked because the import resolves by another route in the contexts CI exercises. conftest.py's comment explains it exists to prevent "52 errors that no CI lane saw"; it has been unable to fire since June. All now use parents[1], which is the service root in both layouts: templates/service/ here, the repo root in a scaffolded service.

test_drills_reproducible.py carried an unreachable DRILL_PYTHONPATH fallback for a layout split the migration closed. Replaced by an assertion that would notice if the layouts ever diverge again, rather than a fallback that quietly stops protecting anything.

The sharpest one: test_memory_contracts.py resolved the service root as REPO_ROOT/"templates"/"service" and skipped if absent. Inside an adopter's service that path does not exist — so the invariant "serving/training code must not import common_utils.memory_types" silently skipped in exactly the environment it exists to protect. Now context-adaptive, and verified to fail on a planted import rather than passing vacuously.

The path gate now reads code comments

Extended from .md/.txt to tracked .py, .yml, .yaml, .sh and Makefile comments. I measured before deciding: 15 unresolved paths across 309 code files — small enough for a hard gate, unlike a naive scan of every string literal. Among them, .security-baselines/tfsec.yml justifying three suppressed HIGH findings against a directory that no longer existed. All 15 resolved.

Three filters the first run demanded, each pinned by a test:

Shape Why it is not a dead path
deploy-*.yml, deploy-{gcp,aws}.yml the token class stops at the metacharacter; without a lookahead the truncated prefix .../deploy- gets reported
ADR-XXX.md, *.local.* a stand-in, and a file that is gitignored by contract — naming it asserts absence, not presence
templates/templates/... punctuation stripping was scrubbing the ellipsis into a clean-looking claim

String literals stay out of scope: a path built at runtime is program logic, not a claim.


Evidence — Schema / Contract Test

templates/tests/unit/test_doc_path_refs_contract.py — extended to 44 cases covering the code-comment scan and all three new filters.

Evidence — Real Execution Output

$ make verify
  ... 14 gates ...
✓ every gate green — safe to push

$ python3 scripts/check_doc_path_refs.py
[doc-path-refs] OK — 595 documents + 330 code files scanned, every repo path
reference resolves (2 baselined, all in-date).

$ # a dead path planted in a YAML comment — invisible before this PR
$ printf '\n# see templates/cicd/ci.yml for the pattern\n' >> .security-baselines/tfsec.yml
$ python3 scripts/check_doc_path_refs.py
  - templates/cicd/ci.yml
      referenced by .security-baselines/tfsec.yml
exit=1

$ # the unpinned Makefile target, caught by the widened scaffold guard
$ python3 scripts/check_adopter_scaffold_ref.py
  - templates/service/Makefile:319: executable `copier update` without --vcs-ref.

$ # after the fix
$ make scaffold-update            # from templates/service/
TEMPLATE_REF is required. Usage: make scaffold-update TEMPLATE_REF=<template release tag>
exit real = 2   ;   copier invocations: 0

$ # C4 now sees the service copy
$ python3 scripts/check_doc_coherence.py
  - [C4 surface-counts] templates/service/CLAUDE.md claims 18 rules + 26 skills
    + 18 workflows; the surface beside it has 19 rules + 27 skills + 20 workflows.

$ python3 -m pytest templates/tests/unit/test_doc_path_refs_contract.py \
    templates/service/tests/test_context_files_hygiene.py -q
76 passed

Evidence — CI Run Link

See the checks on this PR.

🤖 Generated with Claude Code

…nd three guards were blind

Closing the three items left open after #88. Each turned out to sit on top
of a guard that could not see the thing it guarded.

1. templates/service/CLAUDE.md ships into every scaffolded service and still
   described the pre-migration template repo: a templates/ tree with cicd/,
   monitoring/ and common_utils/ that ADR-030 dissolved in June, commands
   telling the adopter to scaffold a service from inside their service, and
   "18 rules + 26 skills + 18 workflows" against a live 19/27/20. Rewritten
   to the service's own layout and command set, with the template-repo audit
   history replaced by an actionable Upstream template section.

   C4 now reconciles BOTH CLAUDE.md files. The service copy was only ever
   checked inside a generated service — by which point it had shipped wrong.

2. The service Makefile ran `copier update --trust --defaults` with no
   --vcs-ref. /scaffold-update was pinned in #71; the Makefile target, the
   other entry point to the same operation, was not. Worse, it reused REF,
   which ci-green defines as `REF ?= main`, so a bare `make scaffold-update`
   updated the service from the moving development branch rather than a
   release. Now TEMPLATE_REF with no default: it refuses to run unpinned,
   exit 2, without invoking copier.

   check_adopter_scaffold_ref.py could not see this: its scan was keyed on
   SCAN_EXTENSIONS and a Makefile has no extension — while its own comment
   warned that "a guard whose coverage is a literal list is only ever as
   complete as the moment someone last remembered to edit it". Scoping by
   extension was the same mistake. Extensionless build files are now
   scanned, and the guard's message no longer describes the v1.x downgrade
   catastrophe that ADR-045 already removed.

3. Three sys.path bridges in templates/service/tests/ probed directories
   where common_utils has not lived since ADR-030 — dead safety nets that
   could never fire, masked because the import resolves by another route in
   the contexts CI exercises. All now use parents[1], the service root in
   both layouts. test_drills_reproducible.py's unreachable DRILL_PYTHONPATH
   fallback became an assertion that would notice a future divergence.

   test_memory_contracts.py resolved the service root as
   REPO_ROOT/"templates"/"service", which does not exist inside an adopter's
   service, so the invariant "serving code must not import
   common_utils.memory_types" silently SKIPPED in exactly the environment it
   protects. Now context-adaptive, and verified to fail on a planted import.

The path gate is extended from .md/.txt to code comments. Measured before
widening: 15 unresolved paths across 309 code files, including
.security-baselines/tfsec.yml justifying three suppressed HIGH findings
against a deleted directory. All 15 resolved. Three filters the first run
demanded are each pinned by a test: glob and brace shorthands no longer
report a truncated prefix, uppercase stand-ins and *.local.* paths are not
claims, and punctuation stripping no longer scrubs an ellipsis into a
clean-looking path. String literals stay out of scope — a path built at
runtime is program logic, not a claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@DuqueOM
DuqueOM merged commit a971a90 into main Sep 5, 2026
25 checks passed
@DuqueOM
DuqueOM deleted the fix/service-context-and-dead-bridges branch September 5, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant