title: Fork-Safe Single-Source Stub Across Heterogeneous Entry Points
date: 2026-07-05
spec: SPEC-150
tags: [single-source, fork-safe, soft-gate, cross-skill, coverage-contract, refactor-hygiene]
category: patterns
module: core-standards
problem_type: process
resolution_type: pattern
severity: high
symptoms:
- The same soft/interactive gate is replicated as an inline copy across many skill entry points, and the copies drift out of sync when the logic changes
- A shared gate that calls AskUserQuestion is dropped into a context: fork skill that forbids interactive prompts, killing the fork mid-run
- "Fix" the fork death by simply removing the gate from the fork skills — which then fails a structural test asserting every entry point invokes the shared script (coverage count regresses, e.g. 25→23)
- N-fold duplicated gate logic makes a single behavior change an N-file edit with a high miss rate
Fork-Safe Single-Source Stub Across Heterogeneous Entry Points¶
Problem¶
A cross-cutting soft gate (a nudge, a check, a reminder) often needs to fire from many entry points. The naive implementation copies the gate's logic inline into each skill — SPEC-150 inherited six such identical "Step 0.5: Project Registration Check" blocks and needed to add five more (11 total). Two forces collide:
- Duplication drift. Eleven inline copies means every future logic change is an eleven-file edit; copies silently diverge.
- Heterogeneous host contexts. The entry points are not uniform. Two of the eleven (
vt-c-4-review,vt-c-5-finalize) run ascontext: forkand forbidAskUserQuestion— a fork that calls it dies. The gate's whole point was to ask ("Register now / Skip for now").
The tempting resolution — just drop the gate from the two fork skills — is a trap: a structural coverage test asserted that all eleven entry points invoke check_registration.sh (the single-source contract). Dropping the block failed two invocation assertions (25→23). Preserving coverage and not killing the fork looked mutually exclusive.
Solution¶
Separate the invocation contract from the interaction behavior, and make the stub degrade per host context:
-
Logic lives in ONE script. All check logic is in
check_registration.sh(single source of truth,${CLAUDE_PLUGIN_ROOT}/skills/project-register/scripts/). Each skill keeps only an import-like call-site stub — never inline logic. -
The invocation is uniform; the reaction is context-aware. Every one of the eleven stubs invokes the script (satisfies the "all N invoke it" test). Interactive hosts branch to the prompt (
AskUserQuestion→ Register / Skip /--record-skip). Fork hosts branch to "report, never ask": on anUNREGISTEREDresult the fork emits one advisory line into its report and does not callAskUserQuestionor--record-skip. Same call, graceful degradation. -
A negation test enforces no backsliding. Beyond the positive "all N contain
check_registration.sh" assertion, a negation assertion checks that no stub re-inlines the logic — so a future edit can't quietly turn the stub back into a twelfth copy.
The result: one place to change behavior, a coverage contract a test can assert, and no fork ever calls a forbidden interactive tool.
Why this is the right generalization¶
- Classify each host's execution context first. Before fanning a shared gate across entry points, enumerate them by context (interactive vs
context: forkvs non-interactive report). The stub's reaction must be designed per-context; only its invocation is uniform. - Make the coverage contract the thing the test asserts, not the interaction. Testing "did it ask?" is brittle and context-dependent; testing "did every entry point invoke the shared script?" is stable and catches both drift and accidental drops.
- Prefer graceful degradation over removal. When a shared capability can't run in its full form in some host, degrade its output (report instead of prompt) rather than removing the invocation — removal breaks the very contract that keeps the single source honest.
Applicability¶
Any time one behavior must fire from a set of entry points that are not homogeneous in what they're allowed to do at runtime: shared pre-flight checks, telemetry hooks, soft gates, banners. Especially where some hosts are non-interactive (forks, CI, headless) but a coverage test still needs to prove universal wiring.
Source: SPEC-150 (PAT-1 resolution). Related: [[cross-skill-script-invocation-gotchas]], structural-test-assertions.md (extract-region-then-assert-membership), count-reconciliation-single-source.md (single authoritative source).