Files
msd-core/docs/reference/gate-predicates.md
sim 9c20b7b40a fix(#4652): use the validated path, collapse the duplication, correct two false claims
Seven findings from the two-axis review, all fixed in place.

THE ONE THAT MATTERS: cmdTodoComplete validated sourcePath and targetPath and
then ran every fs call against the RAW strings — existsSync, statSync,
readFileSync, platformWriteSync, unlinkSync, and the dry-run path payload —
never sourceCheck.resolved / targetCheck.resolved. That is the exact
"validate one path, use another" shape ADR-4650 names as the defect this epic
exists to prevent, and it is the same bug this phase had just fixed in
check-command-router. Committed inside the fix for it. All I/O now uses the
resolved paths; user-facing messages still echo the raw filename, never a
resolved absolute path.

A VACUOUS TEST, and the false doc claim it was propping up. The test
"[RED #4327] an absolute path outside the project is rejected" would have
passed with ZERO containment logic: path.join(pendingDir, '/abs/outside/x')
yields <pendingDir>/abs/outside/x — Node does not let a later absolute segment
escape — so the name is FOLDED under the root, passes containment, and simply
404s. The test only ever observed "Todo not found". It now asserts what is
actually true and actually valuable: an absolute name is neutralized, and the
real outside file is not read, not moved, and still present afterward.
docs/CLI-TOOLS.md claimed such a path "is rejected as a usage error", which
was false; it now describes the fold-under-root behavior. Traversal and
embedded separators ARE rejected, and those claims stand.

DUPLICATION THIS EPIC EXISTS TO REMOVE. resolvePath already did
isAbsolute-or-join + validatePath + reject; cmdGapAnalysisPlanPost and
cmdCheckPredicate each re-inlined the identical triplet in the same file. Both
now call resolvePath. Cost, stated rather than hidden: its generic message
replaces the two sites' distinct "phase-dir escapes…" wording. The message
still names the offending input, and one predicate with one message is the
point.

SYMLINK COVERAGE was required by #4652's "Done when" and was missing. Added
for both the todos root and --phase-dir, skipping cleanly on EPERM so the
Windows lanes do not fail where unprivileged symlink creation is disallowed.

Both fast-check properties were UNSEEDED. Seeded now.

The changeset named "check decision-coverage-plan" as a boundary; that is a
caller of the shared resolvePath, which the body never mentioned. Corrected.

DISCLOSED, not hidden: ctx.phaseDir is now always the resolved ABSOLUTE path,
so ${PHASE_DIR} interpolation and the "not found in <targetDir>" message show
an absolute value where a relative --phase-dir previously produced a relative
one. That is an observable output change. A test pins it and
docs/reference/gate-predicates.md states it.

Also regenerated scripts/lib/platform-conformance-tier.generated.cjs and its
macos twin — the new tests changed check-predicate.test.cjs's tier
classification. Caught by npm run lint:ci locally rather than by a bench run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:43:50 -04:00

6.6 KiB

Gate predicates (reference)

Diátaxis quadrant: Reference. This is the canonical specification of the capability gate check.predicate evaluation path. For a step-by-step authoring guide, see How-to: add a command-exit-zero gate.

A capability gate's check block carries exactly one of three shapes (query, predicate, agentVerdict), enforced by the registry validator (capability-validator.cjs:validateGate). This page documents the predicate shape and the kinds the built-in evaluator recognises.

Declaration

"gates": [
  {
    "point": "<loop-point>",
    "check": {
      "predicate": {
        "kind": "<kind>",
        "<kind-specific fields>"
      }
    },
    "when": "<config-key>",
    "blocking": true,
    "onError": "halt"
  }
]

The gate envelope (point, when, blocking, onError) follows the standard contract documented in ADR-0894 (capability declaration format) and the Loop Host Contract glossary entry in CONTEXT.md. This page covers only check.predicate.

Evaluation path

  1. The loop-resolver (gsd-tools loop render-hooks <point>) renders the active gate hook (including its check.predicate declaration) to the workflow.
  2. The workflow gate-dispatch reads the hook in-context and, when the check shape is predicate, runs:
    gsd_run check predicate --predicate '<predicate JSON>' [--phase-dir …] [--phase-number …] [--phase-req-ids …] --raw
    
  3. check-command-router.cts:cmdCheckPredicate parses the predicate, builds the production subprocess binding, and calls gate-predicate-evaluator.cjs:evaluatePredicate, which dispatches by predicate.kind.
  4. The evaluator returns the standard gate envelope:
    { "block": <bool>, "message": "<string>", "details": { … } }
    
  5. The workflow applies the two-step gate contract unchanged:
    • Step 1 — if the check command itself failed (non-zero exit, e.g. a malformed predicate / unknown kind), route per onError (halt or skip).
    • Step 2 — if the command succeeded, a blocking: true gate halts on block: true; an advisory gate shows message and continues.

Built-in kinds

command-exit-zero

Runs a declared command in a bounded sh -c subprocess; exit 0 → pass, non-zero → block, timeout → block. See ADR-2008 for the full sandbox contract.

Field Type Required Default Notes
kind string yes — Must be "command-exit-zero"
command string yes — The shell command. Non-empty, ≤ 4096 chars
timeout number no 30 Positive finite number, seconds

Interpolation. Before execution, three placeholders are substituted from the gate context; all others are left untouched for sh to interpret:

Placeholder Source Workflow flag
${PHASE_NUMBER} the active phase number --phase-number
${PHASE_DIR} the active phase directory --phase-dir
${PHASE_REQ_IDS} the phase's requirement ids --phase-req-ids

An undefined placeholder interpolates to the empty string.

--phase-dir is confined to the project. The value is validated to resolve inside the project root before any predicate is evaluated; one that escapes is rejected as a usage error rather than evaluated. This applies to both kinds — artifact-frontmatter-equals resolves its artifact under that directory, and command-exit-zero interpolates it into ${PHASE_DIR} — so an unconfined value would let a blocking gate return block: false on evidence from a directory the caller chose (#4354). An absolute path inside the project is still accepted; absolute is not a synonym for escaping. ${PHASE_DIR} always interpolates the resolved absolute path, even when --phase-dir was given as a relative value — a command relying on ${PHASE_DIR} staying relative must not assume that.

Sandbox. cwd = project root; env = inherited from the GSD process; killed (SIGTERM) on timeout. The command runs as the user, on the user's machine — there is no sandbox boundary vs. the user's own shell. See ADR-2008 "Trust model".

Result mapping.

Command outcome block message
exit 0 false command exited 0
exit N (non-zero) true command exited N: <stderr/stdout tail, ≤2000 chars>
timeout (SIGTERM) true command timed out after <s>s: <tail>
sh missing (ENOENT, exit 127) true command exited 127: sh: not found

Validation errors (throw → check-command failure → Step-1 / onError).

  • Missing, non-string, empty, or whitespace-only command.
  • command longer than 4096 chars.
  • timeout present but not a positive finite number.
  • Unknown kind.

artifact-frontmatter-equals

Reads a Markdown file with YAML frontmatter from the current phase directory (or falls back to the project root for project-level artifacts) and compares a field's value to the declared expectation. The value is matched using loosely typed string comparison or exact matching, where numeric expectations will safely match stringified numeric frontmatter values.

Field Type Required Default Notes
kind string yes — Must be "artifact-frontmatter-equals"
artifact string yes — Suffix or exact filename (e.g. WINDOWS.md)
field string yes — Frontmatter key to read
equals any yes — Expected value (compared with string coercion)

Result mapping.

Command outcome block message
Value matches equals false Frontmatter field "<field>" matches expected value (<expected>)
Value mismatch true Frontmatter field "<field>" in <artifact> is <actual>, expected <expected>
Artifact file not found true Artifact matching <artifact> not found in <targetDir>

Validation errors (throw → check-command failure → Step-1 / onError).

  • Missing or empty artifact string.
  • Missing or empty field string.
  • Missing equals value.
  • File read or YAML parsing failure (I/O errors).

Extensibility

The evaluator dispatches through a KIND_TABLE. Adding a new built-in kind is a one-line registration in gate-predicate-evaluator.cts — no workflow changes required, since the workflow dispatches any check.predicate to the same gsd_run check predicate subcommand.