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.
- package/dist/core/DecisionJournal.d.ts +46 -0
- package/dist/core/DecisionJournal.d.ts.map +1 -1
- package/dist/core/DecisionJournal.js +73 -0
- package/dist/core/DecisionJournal.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +10 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +10 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +13 -4
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +63 -63
- package/src/scaffold/templates.ts +10 -0
- package/upgrades/1.3.990.md +61 -0
- package/upgrades/side-effects/decision-journal-principle-required.md +227 -0
|
@@ -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.
|