Skip to content

fix(events): the write core refuses an issue-scoped event with an empty issue (FAFF-1353) - #1094

Merged
alechill merged 7 commits into
mainfrom
FAFF-1353-write-core-refuses-issue-scoped-event-with-empty-issue
Oct 20, 2026
Merged

alechill merged 7 commits into
mainfrom
FAFF-1353-write-core-refuses-issue-scoped-event-with-empty-issue

Conversation

@alechill

@alechill alechill commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A per-issue gate kernel that captures a decision under a live $FAFF_RUN_DIR, with no correlation id or issue exported, wrote a decision-capture row with issue: "". The events write core accepted it, because appendRecordUnderLock only ran the v0.3 payload schema, which allows an empty string. faff events validate then rejected the row, and mintIssueAnchor copied it into every per-issue anchor of the run. Fourteen such rows sit in run-20261018-174517-beepboop-list-2710a1.

The write core now refuses an issue-scoped event with an empty issue, including when the payload check is skipped because of a governance-profile override or an unavailable schema bundle.

This PR also fixes the typecheck job hanging on Node 24.22, which blocked this PR's CI.

Closes FAFF‑1353. Slice 1 of 2 under FAFF‑1348; FAFF‑1354 makes the refusal visible on the capture path.

Changes

  • issueScopedViolations(record, profile) in plugin/skills/faff/bin/lib/events.js, exported. It holds the rule that eventViolations applied inline, with the same violation string. eventViolations calls it at the old position, after the phase and type checks and before the outcome check, so violation order is unchanged.
  • appendRecordUnderLock resolves the governance profile once, before the lock. It passes that profile to logRecordChecker, to the mint callback as a trailing argument, and to issueScopedViolations, which runs on every minted record after the null-record abort. A record that fails raises one EVENT_SCHEMA error listing the issue violation first, then any payload violations. The error's code, message prefix and string array are unchanged.
  • The comment above appendRecordUnderLock no longer says an unavailable bundle "degrades the write to eventViolations alone", which was false.
  • logRecordChecker in evidence.js takes a trailing profile parameter that defaults to activeProfile(). Its two declared-effects.jsonl callers are unchanged.
  • corrective.js passes the profile it receives to its existing eventViolations call, so it no longer reads the override file while events.jsonl.lock is held.
  • The write core applies only the issue-scoped rule, not the full eventViolations vocabulary. Structural writes such as run-start, ledger-write and sentry-trip still go through under a profile override whose vocabulary does not name them.
  • No schema file changes. The spec is committed under records/specs/.

Node 24 typecheck hang

Every typecheck run on Node 24.22.0 hit the job's 20-minute timeout: four on FAFF‑1332's PR and three on this one. Every run on 24.21.0 passed. Each stall was in a test that runs faff validate-adapters on the real tree. That command spawns eval/prefix-planner.mjs, which ended with process.exit(). On Node 24, process.exit() can deadlock while a V8 background job is parked (nodejs/node#64274; fix pending in nodejs/node#66171), and the spawn had no timeout.

  • eval/prefix-planner.mjs: main() returns its exit code and the entry sets process.exitCode, as bin/faff already does. Exit codes are unchanged.
  • validate-adapters.js: the planner call has a 60-second timeout, so a hung planner fails that lint by name instead of stalling the caller.

Verification

  • 17 cases in test/faff-1353-write-core-issue-scoped.test.mjs and one regression case in test/faff-989-decision-capture-driver.test.mjs. Most fail on the base commit. The corrective lock-ordering probe records three reads of the override file on the base, the third while the lock exists, and two reads with no lock after the change.
  • faff events validate --file over all 421 tracked events.jsonl files, with $FAFF_GOVERNANCE_PROFILE unset, gives identical output and exit codes on the base commit and on this branch.
  • Full suite, run as CI's four shards one after another: 7031 pass, 0 fail, 6 skipped.
  • typecheck on Node 24.22.0 after the planner fix: 7033 tests, 0 failures, in 9.5 minutes.
  • Code-blind holdout: declined by the operator for this build.

Review

Two rounds. Standard review: no findings either round. An adversarial second opinion from z-ai/glm-5.3-flash raised two findings in round 1 and carried both into round 2. Both were refuted against the source: validateProfileShape requires issue_scoped_types, and the schema-unavailable marker records only writes that happened. The test now says why no marker is expected. Round 2 raised nothing on the planner fix. Details are on the issue.

Acceptance Criteria

  • faff-1353#done:1 Appending a decision-capture record with issue: "" through appendEventRecord throws EVENT_SCHEMA, its violations name the issue-scoped rule, and events.jsonl is byte-identical before and after the call.
    Verified: Default-profile refusal tests pass: EVENT_SCHEMA names the rule, events.jsonl byte-identical.
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::default profile: a decision-capture append with issue '' is refused and writes nothing
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::default profile: a decision-capture append with no issue key is refused and writes nothing
  • faff-1353#done:2 The same append is refused, with events.jsonl byte-identical, when $FAFF_GOVERNANCE_PROFILE points at a shape-valid profile file whose issue_scoped_types includes the type. The path where logRecordChecker returns null no longer lets the row through.
    Verified: Override declaring the type issue-scoped refuses the append; file byte-identical.
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::profile override declaring the type issue-scoped: the append is refused and writes nothing
  • faff-1353#done:3 The same append is refused, with events.jsonl byte-identical, when the schema bundle is unavailable, set up the way test/evidence-payload-writers.test.mjs:191 does it with pluginCopy plus mintRun. The evidence-schema-unavailable.json marker and stderr line do not let the write through.
    Verified: Plugin copy with run-event.schema.json removed still refuses; file byte-identical.
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::unavailable schema bundle: the append is still refused and writes nothing
  • faff-1353#done:4 git diff against the base names no file under verification/evidence/v0.3/ or plugin/skills/faff/contracts/evidence-v0.3/, and does not name scripts/evidence-v0.3-compose.mjs.
    Verified: git diff cb1a9f1..HEAD names only events.js, evidence.js, corrective.js, two tests and the spec.
    Method: command git diff --name-only cb1a9f15..HEAD
  • faff-1353#done:5 The issue-scoped rule exists once in the tree, as a predicate over a record and a resolved profile, called by both eventViolations and the write core. No second copy of its violation string exists in production code under plugin/skills/faff/bin/lib/ (EVENT_ISSUE_SCOPED at events.js:96 is a delivery-keyed set used only by test/park-reconsider.test.mjs:190; it carries no violation string and is not a second implementation). eventViolations calls it after the phase and type checks and before the outcome check, so for every events.jsonl tracked in git (git ls-files '*events.jsonl', 420 files at HEAD, mostly under .faff/anchors/), faff events validate --file, run with $FAFF_GOVERNANCE_PROFILE unset, prints byte-identical stdout and stderr, and exits with the same code, before and after the change.
    Verified: One predicate, issueScopedViolations; the violation string appears once in bin/lib; validate output identical over 421 tracked events.jsonl files.
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::issueScopedViolations is the rule eventViolations applies, between the type check and the outcome check
    Method: command grep -rn "is issue-scoped but missing required field" plugin/skills/faff/bin/lib/
    Method: command faff events validate --file <f> at cb1a9f15 vs HEAD for every git ls-files '*events.jsonl' (FAFF_GOVERNANCE_PROFILE unset)
  • faff-1353#done:6 Under a $FAFF_GOVERNANCE_PROFILE override whose event_phases and event_types are disjoint from delivery's, emitGenesisRunStart still appends its run-start, and faff run-ledger init-interactive refused with exit 3 because a live L3/L4 run is already resolved (the sentry-trip site at run-ledger.js:315) still appends its sentry-trip row to that live run's events.jsonl. That append sits in a best-effort catch, so the exit code alone proves nothing: the criterion is the row's presence. Neither write throws EVENT_SCHEMA. The core refuses no record over a phase or type its dialect does not name.
    Verified: Under a disjoint override, genesis run-start and the init-interactive exit-3 sentry-trip rows land.
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::disjoint override: emitGenesisRunStart still appends its run-start
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::disjoint override: a refused init-interactive against a live L4 run still appends its sentry-trip
  • faff-1353#done:7 Under that same override, the core still accepts a ledger-write record with no data.ledger_sha256, because the core never runs envelope mode. That rule stays enforced only where it is enforced today.
    Verified: Under a disjoint override, ledger-write with no ledger_sha256 is accepted.
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::disjoint override: the core accepts a ledger-write with no ledger_sha256 because it never runs envelope mode
  • faff-1353#done:8 During one in-process appendEventRecord call under a $FAFF_GOVERNANCE_PROFILE override, the override file is read exactly once, and <run dir>/events.jsonl.lock does not exist at the moment of that read.
    Verified: appendEventRecord reads the override once, lock absent.
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::appendEventRecord under an override reads the override once, with no lock held
  • faff-1353#done:9 During one in-process cmdCorrectiveAuthor call (faff corrective author --op park-with-cause --cites-signal scope-drift --issue FAFF‑1 --cause "probe" --run-dir <R>; --issue is required at corrective.js:431 and park-with-cause needs a non-empty --cause at :435) under a $FAFF_GOVERNANCE_PROFILE override pointing at a JSON copy of DELIVERY_PROFILE:
    Verified: cmdCorrectiveAuthor reads the override twice, never under the lock, event_appended true; probe fails at the base (3 reads, third under lock).
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::faff corrective author under an override reads it twice, never with the lock held, and appends the event
  • faff-1353#done:10 With $FAFF_GOVERNANCE_PROFILE pointing at an unreadable or malformed file, appendEventRecord throws GovernanceProfileError and writes nothing, as that call site does today.
    Verified: Missing and malformed overrides throw GovernanceProfileError; nothing written.
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::an unreadable or malformed override makes appendEventRecord throw GovernanceProfileError and write nothing
  • faff-1353#done:11 A record that fails both halves raises one EVENT_SCHEMA. Its violations array holds the issue-scoped violation followed by the payload violations, all plain strings, and its code and message prefix are unchanged.
    Verified: One EVENT_SCHEMA, issue violation first, plain strings, message prefix unchanged.
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::a record failing both halves raises one EVENT_SCHEMA listing the issue violation first, as plain strings
  • faff-1353#done:12 faff events append of {"phase":"build","type":"build-start"} with no issue still exits 1, prints one stderr line per violation, and creates no events.jsonl. test/events.test.mjs:139 and :244 pass without modification.
    Verified: faff events append pre-check exits 1, no events.jsonl; test/events.test.mjs passes unmodified.
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::faff events append of an issue-scoped type with no issue exits 1 and creates no events.jsonl
    Method: command node --test test/events.test.mjs
  • faff-1353#done:13 All eight appendRecordUnderLock call sites, emitGenesisRunStart included, still append under the default profile. No existing test/*.test.mjs file fails with EVENT_SCHEMA on a path that passed before. The diff leaves the seven mint callbacks other than corrective.js's unchanged.
    Verified: Full suite passes (4 CI shards: 7031 pass, 0 fail, 6 skipped); diff leaves the seven other mint callbacks unchanged.
    Method: command node --import ./test/hermetic-env.mjs --test --test-shard=<n>/4
    Method: command faff gates run --json
  • faff-1353#done:14 The comment above appendRecordUnderLock no longer says an unavailable bundle "degrades the write to eventViolations alone". It states that the issue-scoped rule runs on every minted record, and that an unavailable bundle or a non-delivery profile skips only the payload check.
    Verified: Comment rewritten; the old 'degrades the write to eventViolations alone' text is gone.
    Method: command grep -n "degrades the write to eventViolations alone" plugin/skills/faff/bin/lib/events.js
  • faff-1353#done:15 Under a $FAFF_GOVERNANCE_PROFILE override shaped like SECOND_PROFILE, faff corrective author --json still writes its artifact and reports event_appended: false, and events.jsonl gains no corrective-authored row. The retained in-mint eventViolations call still refuses the record against the override's type list, as it does at HEAD.
    Verified: SECOND_PROFILE-shaped override: artifact written, event_appended false, no row.
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::faff corrective author under a SECOND_PROFILE-shaped override writes its artifact but appends no event
  • faff-1353#done:16 Using a pluginCopy whose run-event.schema.json adds probe_required to the issue-outcome mapping's data.required, run the copy's faff run-ledger record-outcome --issue FAFF‑1 --outcome shipped --run-dir <R> against a run minted with run-ledger init-interactive --issue FAFF‑1. The core refuses the issue-outcome append, run-ledger.json holds the new outcome, the stderr line names the refusal and ends the ledger IS written, the verb exits 0 as it does on success, and events.jsonl gains no issue-outcome row.
    Verified: record-outcome refusal: ledger written, stderr ends 'the ledger IS written', exit 0, no issue-outcome row.
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::record-outcome: a core refusal leaves the ledger written, exits 0 and adds no issue-outcome row
  • faff-1353#done:17 Using a pluginCopy whose run-event.schema.json adds probe_required to the evidence-terminal-only mapping's data.required, run the copy's faff evidence write review-verdict --terminal-only --run-dir <R> --issue ISSUE‑1 with {"signal":"pass","findings":[]} on stdin, from a fixture built like test/evidence-write.test.mjs's. The verb exits 2, stderr carries cannot append the evidence-terminal-only event; nothing written, no review-verdict.json is written, and events.jsonl is byte-identical before and after.
    Verified: terminal-only refusal: exit 2, named stderr, no verdict, events.jsonl byte-identical.
    Method: test test/faff-1353-write-core-issue-scoped.test.mjs::evidence write review-verdict --terminal-only: a core refusal exits 2, writes no verdict and leaves events.jsonl untouched
  • faff-1353#done:18 In a scratch git repo with capture.decision_kernel: on and a genesis run-start minted via faff events append, run FAFF_RUN_DIR=<abs run dir> faff next --status todo --spec high with neither FAFF_DECISION_CORRELATION_ID nor FAFF_DECISION_ISSUE exported. faff next exits 0 and prints its verdict, events.jsonl gains no decision-capture row, and faff events validate --file <that events.jsonl> exits 0.
    Verified: faff next under FAFF_RUN_DIR with no correlation id or issue exits 0, no decision-capture row, validate and verify exit 0.
    Method: test test/faff-989-decision-capture-driver.test.mjs::FAFF‑1353: a kernel run with no correlation id and no issue writes no decision-capture row, and the events file still validates

Closes FAFF-1353

…event with an empty issue

Signed-off-by: Alec Hill <alec@shftwst.dev>
…ty issue (FAFF-1353)

appendRecordUnderLock now applies the issue-scoped rule to every minted
record, including when the payload check is skipped (a non-delivery
profile or an unavailable schema bundle). The rule lives in one predicate,
issueScopedViolations, which eventViolations also calls at its old position.

The core resolves the governance profile once, before the lock, and passes
it to logRecordChecker and to the mint callback. corrective.js uses that
profile instead of resolving its own inside the lock.

Signed-off-by: Alec Hill <alec@shftwst.dev>
Signed-off-by: Alec Hill <alec@shftwst.dev>
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Ready to land. Run this locally in a real terminal:

faff effects declare --run-dir .faff/anchors/run-20261020-134937-graft-FAFF-1353 --issue FAFF-1353 --step merge <<'EOF'
[{"kind":"merge","target":"pr:1094","reversible":true}]
EOF
faff merge-gate --pr 1094 --issue FAFF-1353 --run-dir .faff/anchors/run-20261020-134937-graft-FAFF-1353 --level L2 \
  --execute --merge-args "--squash --delete-branch"

@alechill
alechill marked this pull request as draft October 20, 2026 16:47
…ters cannot hang on Node 24 (FAFF-1353)

Every typecheck run on Node 24.22.0 hit the job's 20-minute timeout, each
time in a test that runs `faff validate-adapters` on the real tree; every
run on 24.21.0 passed. validate-adapters spawns eval/prefix-planner.mjs,
which ended with process.exit(). On Node 24, process.exit() can deadlock
while a V8 background job is parked (nodejs/node#64274, fix pending in
#66171), and the spawn had no timeout.

The planner's main() now returns its exit code and the entry sets
process.exitCode, as bin/faff already does. validate-adapters gives the
planner call a 60-second timeout, so a hung planner fails that lint by
name instead of stalling the caller.

Signed-off-by: Alec Hill <alec@shftwst.dev>
… marker

Signed-off-by: Alec Hill <alec@shftwst.dev>
…ang fix and review round 2

Signed-off-by: Alec Hill <alec@shftwst.dev>
@alechill
alechill marked this pull request as ready for review October 20, 2026 17:39
@alechill
alechill merged commit abd6500 into main Oct 20, 2026
15 checks passed
@alechill
alechill deleted the FAFF-1353-write-core-refuses-issue-scoped-event-with-empty-issue branch October 20, 2026 17:51
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