Skip to content

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:

  1. Duplication drift. Eleven inline copies means every future logic change is an eleven-file edit; copies silently diverge.
  2. Heterogeneous host contexts. The entry points are not uniform. Two of the eleven (vt-c-4-review, vt-c-5-finalize) run as context: fork and forbid AskUserQuestion — 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:

  1. 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.

  2. 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 an UNREGISTERED result the fork emits one advisory line into its report and does not call AskUserQuestion or --record-skip. Same call, graceful degradation.

  3. 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: fork vs 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).