instar 1.3.989 → 1.3.990

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,227 @@
1
+ # Side-Effects Review — a decision log that refuses a decision which does not say why
2
+
3
+ **Version / slug:** `decision-journal-principle-required`
4
+ **Date:** `2026-07-26`
5
+ **Author:** `Echo (instar-dev agent)`
6
+ **Second-pass reviewer:** `see Phase 5`
7
+
8
+ ## Summary of the change
9
+
10
+ Operator directive (topic 29723, 20:4xZ): decide against the goal hierarchy instead of escalating,
11
+ log the reasoning for later review, and **enforce it via infrastructure rather than by remembering**.
12
+
13
+ Investigating what to build produced the finding that the machinery already exists — hierarchy
14
+ (`GET /intent/org`, `/intent/tradeoff-resolve`), recorder (`DecisionJournal`, `POST /intent/journal`),
15
+ and drift detector (`IntentDriftDetector`) — and had recorded **zero decisions, ever**. What is
16
+ missing is not machinery; it is anything that forces its use or reveals its disuse.
17
+
18
+ Then, using it, I produced the defect this PR fixes:
19
+
20
+ - I POSTed `reasoning` and `checkedAgainst`. **Neither is a field.** The route spread `...rest`
21
+ straight through with no validation, so both were persisted where no reader consumes them. The
22
+ write returned 201. I then told my operator the reasoning was recorded — believing it.
23
+ - `principle`, the typed field documented as *"Which AGENT.md principle or intent guided the choice"*,
24
+ was empty on all five entries.
25
+ - `stats()` therefore reported `topPrinciples: []` — **byte-identical to an empty journal.** The
26
+ instrument built to detect unreasoned decisions could not distinguish its own worst case from a
27
+ clean slate.
28
+
29
+ Adds `validateDecisionSubmission()` (pure) + `principledCount`/`unprincipledCount` on
30
+ `DecisionJournalStats`, wires the validator into `POST /intent/journal`, and registers the behaviour
31
+ change for new and existing agents.
32
+
33
+ ## Refusal evidence (constraint 2)
34
+
35
+ ```
36
+ REFUSAL 1 — unwire the validator from the ROUTE (`if (false && !verdict.ok)`)
37
+ UNIT: Tests 11 passed (11) <-- the blindness, reproduced deliberately
38
+ INTEGRATION: × the ROUTE refuses a decision that names no principle
39
+ × the ROUTE refuses fields no reader consumes
40
+ × a refused submission writes NOTHING
41
+ × the refusal message tells the caller where the content belongs
42
+ Tests 4 failed | 2 passed (6)
43
+
44
+ REFUSAL 2 — swallow unknown fields instead of refusing
45
+ × a field no reader consumes is REFUSED, not swallowed
46
+ × missing-required outranks unknown-fields, and still reports both
47
+ × (+3 integration) Tests 5 failed | 12 passed (17)
48
+
49
+ REFUSAL 3 — drop `principle` from the required set
50
+ × a decision naming no guiding principle is REFUSED
51
+ × a blank or whitespace principle does not satisfy the requirement
52
+ × (+3) Tests 5 failed | 12 passed (17)
53
+
54
+ REFUSAL 4 — revert stats to the ambiguous shape
55
+ × entries with no principle are COUNTED, not silently absent → expected +0 to be 2
56
+ × an empty journal is DISTINGUISHABLE from an unprincipled one → expected +0 not to be +0
57
+ × (+2) Tests 4 failed | 13 passed (17)
58
+ ```
59
+
60
+ Restored: **188 passed (188)** across the five affected files, `tsc --noEmit` exit 0.
61
+
62
+ **REFUSAL 1 is the finding, and it is the second occurrence tonight of the same class.** One feature
63
+ earlier (#1658) I emptied a route's registry and all 19 unit tests passed — module guarded, wiring
64
+ not. So here I went looking for it deliberately: unwiring the validator leaves **11/11 unit tests
65
+ green** while the route accepts everything it is supposed to refuse. The integration file exists for
66
+ exactly this assertion and nothing else.
67
+
68
+ ## Decision-point inventory
69
+
70
+ | point | classification | note |
71
+ |---|---|---|
72
+ | missing required field → refuse | `invariant` | Deterministic key/type check. No model call. |
73
+ | unknown field → refuse | `invariant` | Allowlist comparison; allowlist asserted against the documented field set by test. |
74
+ | missing-required outranks unknown-fields | `invariant` | Both are still reported, so one round trip surfaces both problems. |
75
+ | machine dispatch path exempt | `invariant` | Scoped by callsite, not by inspecting content. |
76
+
77
+ No judgment points. No LLM. Nothing is inferred about the *quality* of a cited principle — only that
78
+ one was cited. Judging whether a decision genuinely followed the principle it names is the drift
79
+ detector's job and is deliberately not attempted here.
80
+
81
+ ## 1. Over-block
82
+
83
+ **This is the section that shaped the design.** A blanket requirement would have been wrong.
84
+
85
+ `journal.log()` has two callers: the HTTP route (agent-authored) and `DispatchDecisionJournal
86
+ .logDispatchDecision` via `AutoDispatcher`, which writes auto-applied dispatch decisions whose own
87
+ documented shape is `{ dispatchDecision: 'accept', reasoning: 'auto-applied' }`. Those have no
88
+ principle to cite. **Enforcing inside `log()` would have broken automatic dispatch to buy nothing**,
89
+ so the refusal lives at the route and `log()` is untouched — asserted by a test that the module path
90
+ still accepts an entry with no principle.
91
+
92
+ Residual over-block risk on the agent path: a caller with a legitimate decision and genuinely no
93
+ guiding principle now gets a 400. I accept this deliberately — under the operator directive, a
94
+ decision made during operations without checking the stated goals is precisely what we are
95
+ eliminating. The failure is loud, names the missing field, and is fixed by adding one string.
96
+
97
+ Unknown-field rejection is a strict-schema change and could in principle break an existing caller.
98
+ The refusal names the offending keys and the correct destination, so a broken caller is told exactly
99
+ what to change rather than failing opaquely.
100
+
101
+ **CORRECTION — my first version of this section was too confident, and CI proved it.** I wrote that
102
+ "the journal had `count: 0` on this agent, so no caller has ever successfully written to it" and
103
+ treated that as evidence the back-compat risk was near-nil. That measurement was about the *live
104
+ agent's data file*; it says nothing about *callers in the codebase*. There was one:
105
+ `tests/integration/intent-routes.test.ts` POSTed three decisions with no `principle`, and my change
106
+ refused all three, so the journal file was never created and a later read failed `ENOENT`.
107
+
108
+ I did not catch it locally because I ran a targeted file set that did not include that test. CI did.
109
+ Two failures, both mine, neither a flake. Resolution: the tests now supply a `principle` — which is
110
+ the *correct* fix, since they were writing exactly the unreasoned decisions this gate exists to
111
+ refuse, and their failure is the refusal working on a real caller rather than a synthetic one.
112
+
113
+ The honest generalisation: "no rows in the data file" is not evidence of "no callers in the code".
114
+ Those are different questions and I conflated them. A repo-wide sweep afterwards found three files
115
+ POSTing to the route and six asserting on `stats()` shape; two needed changes, and both are fixed.
116
+
117
+ ## 2. Under-block
118
+
119
+ **It does not force the check, only the citation.** Nothing here fires at the moment a decision is
120
+ made and puts the hierarchy in front of the agent. An agent can still decide without consulting
121
+ `GET /intent/org` and then name a principle after the fact. This PR makes it impossible to *record*
122
+ a decision that claims no guiding intent; it does not make it impossible to *make* one. The
123
+ consult-side trigger is genuinely separate work and is not bundled. <!-- tracked: CMT-1044 -->
124
+
125
+ **It does not judge the principle.** Any non-empty string satisfies the requirement. A caller citing
126
+ "because I felt like it" passes. Judging alignment is `IntentDriftDetector`'s job.
127
+
128
+ **The five existing entries are not repaired.** They keep their unread `reasoning`/`checkedAgainst`
129
+ keys. The new counters will report them as unprincipled, which is accurate. Rewriting my own history
130
+ to look better than it was is the opposite of this tier's purpose.
131
+
132
+ **`GET /intent/journal` (read) is unchanged** and will still return the legacy rows with their
133
+ unread fields. Nothing marks those fields as unread on read.
134
+
135
+ ## 3. Level-of-abstraction fit
136
+
137
+ The validator is pure — no `fs`, no clock, no config, no server import — so the refusal is unit
138
+ testable without a server, and the route owns transport only. This mirrors the structure used one
139
+ feature earlier and, more importantly, is what made REFUSAL 1 possible to demonstrate: a pure
140
+ validator can be perfect while nothing calls it, which is precisely the failure mode being guarded.
141
+
142
+ The counters live on `stats()` rather than in a new surface, because the ambiguity being fixed is a
143
+ property of that existing return value. A new endpoint would have left the misleading one in place.
144
+
145
+ ## 4. Signal vs authority compliance
146
+
147
+ This **is** an authority — it blocks a write — so `docs/signal-vs-authority.md` applies directly
148
+ rather than trivially. It qualifies because the logic is deterministic and total: an allowlist
149
+ comparison and a set of required-key checks, no heuristics, no model, no inference about content.
150
+ That is the category the principle permits to hold blocking authority. Every uncertain input
151
+ (`null`, `undefined`, a string, a number) resolves to a refusal with a named reason rather than a
152
+ throw or a pass — asserted by test.
153
+
154
+ The blocked path writes nothing: a refused submission leaves `count: 0`, asserted by integration
155
+ test. A refusal that still recorded the row would be worse than no refusal, because the journal
156
+ would carry entries the caller was told were rejected.
157
+
158
+ ## 4b. Judgment-point check (Judgment Within Floors standard)
159
+
160
+ None introduced. Every branch is a deterministic key or type comparison.
161
+
162
+ ## 5. Interactions
163
+
164
+ - **`DispatchDecisionJournal` / `AutoDispatcher`** — deliberately NOT gated (see §1). Asserted.
165
+ - **`IntentDriftDetector`** — reads the journal; gains higher-quality input (entries now carry
166
+ `principle`) and is otherwise untouched. Its 16 tests pass unchanged.
167
+ - **`instar intent reflect` / `commands/intent.ts`** — calls `journal.log()` directly, module path,
168
+ unaffected by the route gate.
169
+ - **`tests/unit/DecisionJournal.test.ts`** — one test pinned the exact empty-`stats()` object shape
170
+ and now asserts the two new counters. This is a shape assertion updated to a new shape, not a
171
+ behavioural assertion weakened; the comment records why the zero values matter.
172
+ - **`CapabilityIndex`** — no change needed. `intent` is already classified under
173
+ `INTERNAL_PREFIXES` ("surfaced inside `evolution` subsystems"); this adds no new route prefix.
174
+
175
+ ## 6. External surfaces
176
+
177
+ `POST /intent/journal` changes response behaviour: submissions that previously returned 201 may now
178
+ return 400 with `{ error, reason, unknownFields, missingFields }`. `GET /intent/journal/stats` gains
179
+ two fields (additive). No config key, no new route, no persisted-state migration, no message to any
180
+ user. No credentials or content beyond what the caller submitted appear in any response.
181
+
182
+ ## 6b. Operator-surface quality
183
+
184
+ The refusal message names the offending fields AND the correct destination (`context` for reasoning,
185
+ `principle` for guiding intent) plus the full writable-field list. A refusal that only says "invalid"
186
+ relocates the failure rather than fixing it; asserted by a test that the message mentions `context`.
187
+
188
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
189
+
190
+ **Machine-local BY DESIGN.** The journal is a per-machine JSONL under `stateDir`; the validator is
191
+ pure and stateless, so the refusal is identical on every machine with no coordination required.
192
+ There is no replication, no lease interaction, no generated URL, and no cross-machine read. Honest
193
+ limitation: an agent running on two machines keeps two separate decision journals, so
194
+ `principledCount` answers "on this machine". That predates this change and is not addressed here.
195
+ <!-- tracked: CMT-1044 -->
196
+
197
+ ## 8. Rollback cost
198
+
199
+ Low. One pure function, two counters, one route guard, plus doc/migration lines. No persisted-state
200
+ change and no data migration — existing rows are read unchanged. Reverting restores the permissive
201
+ route; already-written entries remain valid under both versions.
202
+
203
+ ## Phase 5 — Second-pass review
204
+
205
+ This change **does** hold block/allow authority on a write path, so the high-risk trigger list is
206
+ engaged. It does not touch session lifecycle, messaging, dispatch, trust levels, or recovery. Author
207
+ lenses, disclosed:
208
+
209
+ **Adversarial — "how would I make this useless?"** Three ways, all closed and asserted: let the
210
+ route skip the validator (REFUSAL 1 — the real one); accept unknown keys silently (REFUSAL 2); drop
211
+ the principle requirement (REFUSAL 3). A fourth — refuse but write the row anyway — is closed by the
212
+ `a refused submission writes NOTHING` test.
213
+
214
+ **"Would it have caught the incident?"** Yes, on the first attempt, which is the strongest thing I
215
+ can say for it. My literal opening submission is now a test fixture and returns 400 naming
216
+ `checkedAgainst` and `reasoning`. I would have lost seconds instead of finding out by reading my own
217
+ file afterwards and having already told my operator otherwise.
218
+
219
+ **"Symptom or cause?"** Partly cause, and I want this stated plainly rather than implied: it makes
220
+ an unreasoned decision unrecordable, which is a real structural gate. It does not make an unreasoned
221
+ decision unmakeable. The operator asked for decisions checked against the hierarchy; this delivers
222
+ the enforcement half of "log the reasoning" and none of the consult half.
223
+
224
+ **Weakest point:** the requirement is satisfiable by any non-empty string. Nothing distinguishes a
225
+ genuinely-consulted principle from a plausible-sounding one typed to clear the gate — and the agent
226
+ clearing it is the same one whose unreliability motivated the gate. That limit is inherent to a
227
+ deterministic check and is the reason the consult-side trigger is not optional future work.